@plmbr/notebook-intelligence 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +674 -0
- package/README.md +412 -0
- package/lib/api.d.ts +288 -0
- package/lib/api.js +927 -0
- package/lib/cell-output-bundle.d.ts +25 -0
- package/lib/cell-output-bundle.js +129 -0
- package/lib/cell-output-toolbar.d.ts +26 -0
- package/lib/cell-output-toolbar.js +188 -0
- package/lib/chat-progress-feedback.d.ts +3 -0
- package/lib/chat-progress-feedback.js +27 -0
- package/lib/chat-sidebar.d.ts +92 -0
- package/lib/chat-sidebar.js +3452 -0
- package/lib/command-ids.d.ts +39 -0
- package/lib/command-ids.js +44 -0
- package/lib/components/ask-user-question.d.ts +2 -0
- package/lib/components/ask-user-question.js +85 -0
- package/lib/components/checkbox.d.ts +2 -0
- package/lib/components/checkbox.js +30 -0
- package/lib/components/claude-mcp-panel.d.ts +2 -0
- package/lib/components/claude-mcp-panel.js +275 -0
- package/lib/components/claude-mcp-paste.d.ts +7 -0
- package/lib/components/claude-mcp-paste.js +104 -0
- package/lib/components/claude-session-picker.d.ts +8 -0
- package/lib/components/claude-session-picker.js +127 -0
- package/lib/components/form-dialog.d.ts +25 -0
- package/lib/components/form-dialog.js +35 -0
- package/lib/components/launcher-picker.d.ts +6 -0
- package/lib/components/launcher-picker.js +135 -0
- package/lib/components/mcp-util.d.ts +2 -0
- package/lib/components/mcp-util.js +37 -0
- package/lib/components/notebook-generation-popover.d.ts +7 -0
- package/lib/components/notebook-generation-popover.js +60 -0
- package/lib/components/pill.d.ts +2 -0
- package/lib/components/pill.js +5 -0
- package/lib/components/plugins-panel.d.ts +3 -0
- package/lib/components/plugins-panel.js +466 -0
- package/lib/components/settings-panel.d.ts +11 -0
- package/lib/components/settings-panel.js +742 -0
- package/lib/components/skills-panel.d.ts +2 -0
- package/lib/components/skills-panel.js +1264 -0
- package/lib/handler.d.ts +8 -0
- package/lib/handler.js +36 -0
- package/lib/icons.d.ts +45 -0
- package/lib/icons.js +54 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.js +2079 -0
- package/lib/markdown-renderer.d.ts +10 -0
- package/lib/markdown-renderer.js +64 -0
- package/lib/notebook-generation-toolbar.d.ts +16 -0
- package/lib/notebook-generation-toolbar.js +197 -0
- package/lib/notebook-generation.d.ts +8 -0
- package/lib/notebook-generation.js +12 -0
- package/lib/open-file-refresh-watcher-env.d.ts +4 -0
- package/lib/open-file-refresh-watcher-env.js +33 -0
- package/lib/open-file-refresh-watcher.d.ts +97 -0
- package/lib/open-file-refresh-watcher.js +190 -0
- package/lib/shell-utils.d.ts +6 -0
- package/lib/shell-utils.js +9 -0
- package/lib/task-target-notebook.d.ts +2 -0
- package/lib/task-target-notebook.js +28 -0
- package/lib/terminal-drag-format.d.ts +9 -0
- package/lib/terminal-drag-format.js +23 -0
- package/lib/terminal-drag.d.ts +12 -0
- package/lib/terminal-drag.js +268 -0
- package/lib/tokens.d.ts +149 -0
- package/lib/tokens.js +88 -0
- package/lib/tour/tour-anchors.d.ts +18 -0
- package/lib/tour/tour-anchors.js +18 -0
- package/lib/tour/tour-config.d.ts +66 -0
- package/lib/tour/tour-config.js +99 -0
- package/lib/tour/tour-defaults.json +58 -0
- package/lib/tour/tour-events.d.ts +19 -0
- package/lib/tour/tour-events.js +30 -0
- package/lib/tour/tour-overlay.d.ts +6 -0
- package/lib/tour/tour-overlay.js +350 -0
- package/lib/tour/tour-state.d.ts +20 -0
- package/lib/tour/tour-state.js +81 -0
- package/lib/tour/tour-steps.d.ts +33 -0
- package/lib/tour/tour-steps.js +216 -0
- package/lib/utils.d.ts +53 -0
- package/lib/utils.js +385 -0
- package/package.json +258 -0
- package/schema/plugin.json +42 -0
- package/src/api.ts +1424 -0
- package/src/cell-output-bundle.ts +176 -0
- package/src/cell-output-toolbar.ts +232 -0
- package/src/chat-progress-feedback.ts +35 -0
- package/src/chat-sidebar.tsx +5147 -0
- package/src/command-ids.ts +67 -0
- package/src/components/ask-user-question.tsx +151 -0
- package/src/components/checkbox.tsx +62 -0
- package/src/components/claude-mcp-panel.tsx +543 -0
- package/src/components/claude-mcp-paste.ts +132 -0
- package/src/components/claude-session-picker.tsx +214 -0
- package/src/components/form-dialog.tsx +75 -0
- package/src/components/launcher-picker.tsx +237 -0
- package/src/components/mcp-util.ts +53 -0
- package/src/components/notebook-generation-popover.tsx +127 -0
- package/src/components/pill.tsx +15 -0
- package/src/components/plugins-panel.tsx +774 -0
- package/src/components/settings-panel.tsx +1631 -0
- package/src/components/skills-panel.tsx +2084 -0
- package/src/handler.ts +51 -0
- package/src/icons.ts +71 -0
- package/src/index.ts +2583 -0
- package/src/markdown-renderer.tsx +153 -0
- package/src/notebook-generation-toolbar.tsx +281 -0
- package/src/notebook-generation.ts +23 -0
- package/src/open-file-refresh-watcher-env.ts +52 -0
- package/src/open-file-refresh-watcher.ts +260 -0
- package/src/shell-utils.ts +10 -0
- package/src/svg.d.ts +4 -0
- package/src/task-target-notebook.ts +37 -0
- package/src/terminal-drag-format.ts +29 -0
- package/src/terminal-drag.ts +382 -0
- package/src/tokens.ts +171 -0
- package/src/tour/tour-anchors.ts +21 -0
- package/src/tour/tour-config.ts +160 -0
- package/src/tour/tour-events.ts +34 -0
- package/src/tour/tour-overlay.tsx +474 -0
- package/src/tour/tour-state.ts +87 -0
- package/src/tour/tour-steps.ts +281 -0
- package/src/utils.ts +455 -0
- package/style/base.css +3238 -0
- package/style/icons/cell-toolbar-bug.svg +5 -0
- package/style/icons/cell-toolbar-chat.svg +5 -0
- package/style/icons/cell-toolbar-sparkle.svg +5 -0
- package/style/icons/claude.svg +1 -0
- package/style/icons/copilot-warning.svg +1 -0
- package/style/icons/copilot.svg +1 -0
- package/style/icons/copy.svg +1 -0
- package/style/icons/openai.svg +1 -0
- package/style/icons/opencode.svg +1 -0
- package/style/icons/sparkles-warning.svg +5 -0
- package/style/icons/sparkles.svg +1 -0
- package/style/index.css +1 -0
- package/style/index.js +1 -0
|
@@ -0,0 +1,2084 @@
|
|
|
1
|
+
// Copyright (c) Mehmet Bektas <mbektasgh@outlook.com>
|
|
2
|
+
|
|
3
|
+
import React, {
|
|
4
|
+
useEffect,
|
|
5
|
+
useLayoutEffect,
|
|
6
|
+
useMemo,
|
|
7
|
+
useRef,
|
|
8
|
+
useState
|
|
9
|
+
} from 'react';
|
|
10
|
+
import { Dialog, showDialog } from '@jupyterlab/apputils';
|
|
11
|
+
import { VscCopy, VscEdit, VscTrash } from '../icons';
|
|
12
|
+
import {
|
|
13
|
+
ISkillDetail,
|
|
14
|
+
ISkillImportPreview,
|
|
15
|
+
ISkillsContext,
|
|
16
|
+
ISkillSummary,
|
|
17
|
+
NBIAPI,
|
|
18
|
+
SkillScope
|
|
19
|
+
} from '../api';
|
|
20
|
+
|
|
21
|
+
// Closes the enclosing modal on document-level Escape, regardless of which
|
|
22
|
+
// element inside the dialog has focus. The previous per-input handler only
|
|
23
|
+
// fired while the URL field was focused, leaving keyboard users stuck once
|
|
24
|
+
// they tabbed onto a button.
|
|
25
|
+
function useEscapeKey(onEscape: () => void): void {
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
const handler = (e: KeyboardEvent) => {
|
|
28
|
+
if (e.key === 'Escape') {
|
|
29
|
+
e.preventDefault();
|
|
30
|
+
onEscape();
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
document.addEventListener('keydown', handler);
|
|
34
|
+
return () => document.removeEventListener('keydown', handler);
|
|
35
|
+
}, [onEscape]);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const FOCUSABLE_SELECTOR =
|
|
39
|
+
'a[href], button:not([disabled]), textarea:not([disabled]), input:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
|
40
|
+
|
|
41
|
+
// Constrain Tab / Shift+Tab to cycle within ``container``. Without this, Tab
|
|
42
|
+
// from the last button in the modal escapes into the lab toolbar — keyboard
|
|
43
|
+
// users lose the dialog. ARIA APG's modal pattern requires the trap.
|
|
44
|
+
function useFocusTrap(container: React.RefObject<HTMLElement>): void {
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
const node = container.current;
|
|
47
|
+
if (!node) {
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const handler = (e: KeyboardEvent) => {
|
|
51
|
+
if (e.key !== 'Tab') {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const focusables = Array.from(
|
|
55
|
+
node.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)
|
|
56
|
+
).filter(el => !el.hasAttribute('disabled'));
|
|
57
|
+
if (focusables.length === 0) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const first = focusables[0];
|
|
61
|
+
const last = focusables[focusables.length - 1];
|
|
62
|
+
const active = document.activeElement;
|
|
63
|
+
if (e.shiftKey && active === first) {
|
|
64
|
+
e.preventDefault();
|
|
65
|
+
last.focus();
|
|
66
|
+
} else if (!e.shiftKey && active === last) {
|
|
67
|
+
e.preventDefault();
|
|
68
|
+
first.focus();
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
node.addEventListener('keydown', handler);
|
|
72
|
+
return () => node.removeEventListener('keydown', handler);
|
|
73
|
+
}, [container]);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Must match SKILL_NAME_PATTERN in notebook_intelligence/skillset.py
|
|
77
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,63}$/;
|
|
78
|
+
const SKILL_NAME_REQUIREMENT =
|
|
79
|
+
'Must be lowercase letters, digits, or hyphens (starting with a letter or digit), max 64 chars.';
|
|
80
|
+
const SKILL_ENTRY_FILE = 'SKILL.md';
|
|
81
|
+
// Bundles with more files than this collapse their file tabs to just SKILL.md.
|
|
82
|
+
// Keeps the tab strip usable for reference-data skills that ship hundreds of
|
|
83
|
+
// helper files; those are easier to edit on disk anyway.
|
|
84
|
+
const BUNDLE_FILE_DISPLAY_LIMIT = 20;
|
|
85
|
+
const COMMON_TOOLS = [
|
|
86
|
+
'Read',
|
|
87
|
+
'Write',
|
|
88
|
+
'Edit',
|
|
89
|
+
'Bash',
|
|
90
|
+
'Glob',
|
|
91
|
+
'Grep',
|
|
92
|
+
'Task',
|
|
93
|
+
'TodoWrite',
|
|
94
|
+
'WebFetch',
|
|
95
|
+
'WebSearch',
|
|
96
|
+
'NotebookEdit'
|
|
97
|
+
];
|
|
98
|
+
|
|
99
|
+
type ViewMode =
|
|
100
|
+
| { kind: 'list' }
|
|
101
|
+
| { kind: 'editor'; scope: SkillScope; name: string | null };
|
|
102
|
+
|
|
103
|
+
type PromptMode =
|
|
104
|
+
| null
|
|
105
|
+
| { kind: 'rename'; skill: ISkillSummary }
|
|
106
|
+
| { kind: 'duplicate'; skill: ISkillSummary };
|
|
107
|
+
|
|
108
|
+
interface IUndoState {
|
|
109
|
+
detail: ISkillDetail;
|
|
110
|
+
bundleFiles: { path: string; content: string }[];
|
|
111
|
+
timerId: number;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function SettingsPanelComponentSkills(_props: any): JSX.Element {
|
|
115
|
+
const [skills, setSkills] = useState<ISkillSummary[]>([]);
|
|
116
|
+
const [context, setContext] = useState<ISkillsContext | null>(null);
|
|
117
|
+
const [loading, setLoading] = useState(true);
|
|
118
|
+
const [error, setError] = useState<string | null>(null);
|
|
119
|
+
const [view, setView] = useState<ViewMode>({ kind: 'list' });
|
|
120
|
+
const [prompt, setPrompt] = useState<PromptMode>(null);
|
|
121
|
+
const [undo, setUndo] = useState<IUndoState | null>(null);
|
|
122
|
+
const [importOpen, setImportOpen] = useState(false);
|
|
123
|
+
const [syncing, setSyncing] = useState(false);
|
|
124
|
+
const [syncMessage, setSyncMessage] = useState<string | null>(null);
|
|
125
|
+
const [allowGithubImport, setAllowGithubImport] = useState(
|
|
126
|
+
NBIAPI.config.allowGithubSkillImport
|
|
127
|
+
);
|
|
128
|
+
const hasManagedSkills = skills.some(s => s.managed);
|
|
129
|
+
const hasTrackingSkills = skills.some(s => s.tracksUpstream);
|
|
130
|
+
|
|
131
|
+
const refresh = async () => {
|
|
132
|
+
setLoading(true);
|
|
133
|
+
setError(null);
|
|
134
|
+
try {
|
|
135
|
+
const list = await NBIAPI.listSkills();
|
|
136
|
+
setSkills(list);
|
|
137
|
+
} catch (e: any) {
|
|
138
|
+
setError(e?.message ?? String(e));
|
|
139
|
+
} finally {
|
|
140
|
+
setLoading(false);
|
|
141
|
+
}
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
useEffect(() => {
|
|
145
|
+
refresh();
|
|
146
|
+
NBIAPI.getSkillsContext()
|
|
147
|
+
.then(setContext)
|
|
148
|
+
.catch(() => {
|
|
149
|
+
// Non-fatal — panel still works without the context hints.
|
|
150
|
+
});
|
|
151
|
+
const listener = () => {
|
|
152
|
+
refresh();
|
|
153
|
+
};
|
|
154
|
+
const configListener = () => {
|
|
155
|
+
setAllowGithubImport(NBIAPI.config.allowGithubSkillImport);
|
|
156
|
+
};
|
|
157
|
+
NBIAPI.skillsReloaded.connect(listener);
|
|
158
|
+
NBIAPI.configChanged.connect(configListener);
|
|
159
|
+
return () => {
|
|
160
|
+
NBIAPI.skillsReloaded.disconnect(listener);
|
|
161
|
+
NBIAPI.configChanged.disconnect(configListener);
|
|
162
|
+
};
|
|
163
|
+
}, []);
|
|
164
|
+
|
|
165
|
+
const undoRef = useRef<IUndoState | null>(null);
|
|
166
|
+
undoRef.current = undo;
|
|
167
|
+
|
|
168
|
+
const dismissUndo = () => {
|
|
169
|
+
setUndo(prev => {
|
|
170
|
+
if (prev) {
|
|
171
|
+
window.clearTimeout(prev.timerId);
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
});
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
useEffect(() => {
|
|
178
|
+
return () => {
|
|
179
|
+
if (undoRef.current) {
|
|
180
|
+
window.clearTimeout(undoRef.current.timerId);
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}, []);
|
|
184
|
+
|
|
185
|
+
const handleSyncManaged = async () => {
|
|
186
|
+
setSyncing(true);
|
|
187
|
+
setSyncMessage(null);
|
|
188
|
+
setError(null);
|
|
189
|
+
try {
|
|
190
|
+
const r = await NBIAPI.reconcileManagedSkills();
|
|
191
|
+
const summary = `Sync complete. ${r.added} added, ${r.updated} updated, ${r.removed} removed, ${r.unchanged} unchanged.`;
|
|
192
|
+
setSyncMessage(
|
|
193
|
+
r.errors.length ? `${summary} (${r.errors.length} error(s))` : summary
|
|
194
|
+
);
|
|
195
|
+
if (r.errors.length) {
|
|
196
|
+
setError(r.errors.join('\n'));
|
|
197
|
+
}
|
|
198
|
+
await refresh();
|
|
199
|
+
} catch (e: any) {
|
|
200
|
+
setError(`Sync failed: ${e?.message ?? e}`);
|
|
201
|
+
} finally {
|
|
202
|
+
setSyncing(false);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
const handleSyncTracking = async (skill: ISkillSummary) => {
|
|
207
|
+
setSyncing(true);
|
|
208
|
+
setSyncMessage(null);
|
|
209
|
+
setError(null);
|
|
210
|
+
try {
|
|
211
|
+
const r = await NBIAPI.syncTrackingSkill(skill.scope, skill.name);
|
|
212
|
+
setSyncMessage(
|
|
213
|
+
r.updated
|
|
214
|
+
? `Synced "${skill.name}" to ${r.ref.slice(0, 7)}.`
|
|
215
|
+
: `"${skill.name}" already up to date at ${r.ref.slice(0, 7)}.`
|
|
216
|
+
);
|
|
217
|
+
await refresh();
|
|
218
|
+
} catch (e: any) {
|
|
219
|
+
setError(`Sync failed for "${skill.name}": ${e?.message ?? e}`);
|
|
220
|
+
} finally {
|
|
221
|
+
setSyncing(false);
|
|
222
|
+
}
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const handleSyncAllTracking = async () => {
|
|
226
|
+
setSyncing(true);
|
|
227
|
+
setSyncMessage(null);
|
|
228
|
+
setError(null);
|
|
229
|
+
try {
|
|
230
|
+
const results = await NBIAPI.syncAllTrackingSkills();
|
|
231
|
+
const updated = results.filter(r => r.updated).length;
|
|
232
|
+
const unchanged = results.filter(r => r.updated === false).length;
|
|
233
|
+
const errors = results.filter(r => r.error);
|
|
234
|
+
const summary = `Sync complete. ${updated} updated, ${unchanged} unchanged, ${errors.length} error(s).`;
|
|
235
|
+
setSyncMessage(summary);
|
|
236
|
+
if (errors.length) {
|
|
237
|
+
setError(errors.map(r => `${r.name}: ${r.error}`).join('\n'));
|
|
238
|
+
}
|
|
239
|
+
await refresh();
|
|
240
|
+
} catch (e: any) {
|
|
241
|
+
setError(`Sync failed: ${e?.message ?? e}`);
|
|
242
|
+
} finally {
|
|
243
|
+
setSyncing(false);
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const handleDelete = async (skill: ISkillSummary) => {
|
|
248
|
+
const result = await showDialog({
|
|
249
|
+
title: 'Delete skill?',
|
|
250
|
+
body: `"${skill.name}" will be deleted.`,
|
|
251
|
+
buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Delete' })]
|
|
252
|
+
});
|
|
253
|
+
if (!result.button.accept) {
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
// Snapshot the full bundle (SKILL.md + helper files) *before* deleting so the Undo
|
|
257
|
+
// toast can recreate it byte-for-byte. The backend only exposes a shallow delete
|
|
258
|
+
// API, so restoration is the client's job.
|
|
259
|
+
let detail: ISkillDetail;
|
|
260
|
+
try {
|
|
261
|
+
detail = await NBIAPI.readSkill(skill.scope, skill.name);
|
|
262
|
+
} catch (e: any) {
|
|
263
|
+
setError(`Failed to read skill: ${e?.message ?? e}`);
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const pathsToSnapshot = (detail.files ?? []).filter(
|
|
267
|
+
p => p !== SKILL_ENTRY_FILE
|
|
268
|
+
);
|
|
269
|
+
const snapshots = await Promise.all(
|
|
270
|
+
pathsToSnapshot.map(async path => {
|
|
271
|
+
try {
|
|
272
|
+
const content = await NBIAPI.readBundleFile(
|
|
273
|
+
skill.scope,
|
|
274
|
+
skill.name,
|
|
275
|
+
path
|
|
276
|
+
);
|
|
277
|
+
return { path, content };
|
|
278
|
+
} catch {
|
|
279
|
+
// Best-effort snapshot — skip unreadable files.
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
})
|
|
283
|
+
);
|
|
284
|
+
const bundleFiles = snapshots.filter(
|
|
285
|
+
(s): s is { path: string; content: string } => s !== null
|
|
286
|
+
);
|
|
287
|
+
try {
|
|
288
|
+
await NBIAPI.deleteSkill(skill.scope, skill.name);
|
|
289
|
+
await refresh();
|
|
290
|
+
} catch (e: any) {
|
|
291
|
+
setError(`Failed to delete skill: ${e?.message ?? e}`);
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
dismissUndo();
|
|
295
|
+
const timerId = window.setTimeout(() => {
|
|
296
|
+
setUndo(null);
|
|
297
|
+
}, 8000);
|
|
298
|
+
setUndo({ detail, bundleFiles, timerId });
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
const handleUndoDelete = async () => {
|
|
302
|
+
if (!undo) {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
const { detail, bundleFiles } = undo;
|
|
306
|
+
dismissUndo();
|
|
307
|
+
try {
|
|
308
|
+
await NBIAPI.createSkill({
|
|
309
|
+
scope: detail.scope,
|
|
310
|
+
name: detail.name,
|
|
311
|
+
description: detail.description,
|
|
312
|
+
allowedTools: detail.allowedTools,
|
|
313
|
+
body: detail.body
|
|
314
|
+
});
|
|
315
|
+
await Promise.all(
|
|
316
|
+
bundleFiles.map(({ path, content }) =>
|
|
317
|
+
NBIAPI.writeBundleFile(
|
|
318
|
+
detail.scope,
|
|
319
|
+
detail.name,
|
|
320
|
+
path,
|
|
321
|
+
content
|
|
322
|
+
).catch(() => {
|
|
323
|
+
// Non-fatal — skill is restored even if a bundle file fails.
|
|
324
|
+
})
|
|
325
|
+
)
|
|
326
|
+
);
|
|
327
|
+
await refresh();
|
|
328
|
+
} catch (e: any) {
|
|
329
|
+
setError(`Failed to restore skill: ${e?.message ?? e}`);
|
|
330
|
+
}
|
|
331
|
+
};
|
|
332
|
+
|
|
333
|
+
const handleRename = (skill: ISkillSummary) => {
|
|
334
|
+
setPrompt({ kind: 'rename', skill });
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
const handleDuplicate = (skill: ISkillSummary) => {
|
|
338
|
+
setPrompt({ kind: 'duplicate', skill });
|
|
339
|
+
};
|
|
340
|
+
|
|
341
|
+
const commitRename = async (skill: ISkillSummary, newName: string) => {
|
|
342
|
+
await NBIAPI.renameSkill(skill.scope, skill.name, newName);
|
|
343
|
+
setPrompt(null);
|
|
344
|
+
await refresh();
|
|
345
|
+
};
|
|
346
|
+
|
|
347
|
+
const commitDuplicate = async (
|
|
348
|
+
skill: ISkillSummary,
|
|
349
|
+
targetScope: SkillScope,
|
|
350
|
+
newName: string
|
|
351
|
+
) => {
|
|
352
|
+
const detail = await NBIAPI.readSkill(skill.scope, skill.name);
|
|
353
|
+
await NBIAPI.createSkill({
|
|
354
|
+
scope: targetScope,
|
|
355
|
+
name: newName,
|
|
356
|
+
description: detail.description,
|
|
357
|
+
allowedTools: detail.allowedTools,
|
|
358
|
+
body: detail.body
|
|
359
|
+
});
|
|
360
|
+
const filesToCopy = detail.files.filter(f => f !== SKILL_ENTRY_FILE);
|
|
361
|
+
await Promise.all(
|
|
362
|
+
filesToCopy.map(async file => {
|
|
363
|
+
const content = await NBIAPI.readBundleFile(
|
|
364
|
+
skill.scope,
|
|
365
|
+
skill.name,
|
|
366
|
+
file
|
|
367
|
+
);
|
|
368
|
+
await NBIAPI.writeBundleFile(targetScope, newName, file, content);
|
|
369
|
+
})
|
|
370
|
+
);
|
|
371
|
+
setPrompt(null);
|
|
372
|
+
await refresh();
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
if (view.kind === 'editor') {
|
|
376
|
+
return (
|
|
377
|
+
<SkillEditor
|
|
378
|
+
scope={view.scope}
|
|
379
|
+
name={view.name}
|
|
380
|
+
onClose={async () => {
|
|
381
|
+
await refresh();
|
|
382
|
+
setView({ kind: 'list' });
|
|
383
|
+
}}
|
|
384
|
+
/>
|
|
385
|
+
);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
const userSkills = skills.filter(s => s.scope === 'user');
|
|
389
|
+
const projectSkills = skills.filter(s => s.scope === 'project');
|
|
390
|
+
|
|
391
|
+
return (
|
|
392
|
+
<div className="config-dialog-body nbi-skills-panel">
|
|
393
|
+
<div className="nbi-skills-header">
|
|
394
|
+
<div className="nbi-skills-title">Skills</div>
|
|
395
|
+
<div className="nbi-skills-header-actions">
|
|
396
|
+
{hasManagedSkills && (
|
|
397
|
+
<button
|
|
398
|
+
className="jp-Dialog-button jp-mod-reject jp-mod-styled"
|
|
399
|
+
onClick={handleSyncManaged}
|
|
400
|
+
disabled={syncing}
|
|
401
|
+
title="Reconcile managed skills against the org manifest"
|
|
402
|
+
>
|
|
403
|
+
<div className="jp-Dialog-buttonLabel">
|
|
404
|
+
{syncing ? 'Syncing…' : 'Sync managed skills'}
|
|
405
|
+
</div>
|
|
406
|
+
</button>
|
|
407
|
+
)}
|
|
408
|
+
{hasTrackingSkills && allowGithubImport && (
|
|
409
|
+
<button
|
|
410
|
+
className="jp-Dialog-button jp-mod-reject jp-mod-styled"
|
|
411
|
+
onClick={handleSyncAllTracking}
|
|
412
|
+
disabled={syncing}
|
|
413
|
+
title="Re-fetch every skill set to track upstream from GitHub"
|
|
414
|
+
>
|
|
415
|
+
<div className="jp-Dialog-buttonLabel">
|
|
416
|
+
{syncing ? 'Syncing…' : 'Sync tracking skills'}
|
|
417
|
+
</div>
|
|
418
|
+
</button>
|
|
419
|
+
)}
|
|
420
|
+
{allowGithubImport && (
|
|
421
|
+
<button
|
|
422
|
+
className="jp-Dialog-button jp-mod-reject jp-mod-styled"
|
|
423
|
+
onClick={() => setImportOpen(true)}
|
|
424
|
+
title="Import from GitHub"
|
|
425
|
+
>
|
|
426
|
+
<div className="jp-Dialog-buttonLabel">
|
|
427
|
+
{hasManagedSkills ? 'Import' : 'Import from GitHub'}
|
|
428
|
+
</div>
|
|
429
|
+
</button>
|
|
430
|
+
)}
|
|
431
|
+
<button
|
|
432
|
+
className="jp-Dialog-button jp-mod-reject jp-mod-styled"
|
|
433
|
+
onClick={() =>
|
|
434
|
+
setView({ kind: 'editor', scope: 'user', name: null })
|
|
435
|
+
}
|
|
436
|
+
>
|
|
437
|
+
<div className="jp-Dialog-buttonLabel">New Skill</div>
|
|
438
|
+
</button>
|
|
439
|
+
</div>
|
|
440
|
+
</div>
|
|
441
|
+
{!NBIAPI.config.isInClaudeCodeMode && (
|
|
442
|
+
<div className="nbi-skills-info-banner" role="note">
|
|
443
|
+
Skills are consumed by Claude Code. Enable Claude mode in the Claude
|
|
444
|
+
settings tab to use skills you author here.
|
|
445
|
+
</div>
|
|
446
|
+
)}
|
|
447
|
+
{syncMessage && (
|
|
448
|
+
<div className="nbi-skills-sync-message" role="status">
|
|
449
|
+
{syncMessage}
|
|
450
|
+
</div>
|
|
451
|
+
)}
|
|
452
|
+
{error && (
|
|
453
|
+
<div className="nbi-skills-error" role="alert">
|
|
454
|
+
{error}
|
|
455
|
+
</div>
|
|
456
|
+
)}
|
|
457
|
+
<SkillScopeSection
|
|
458
|
+
scope="user"
|
|
459
|
+
label="USER"
|
|
460
|
+
pathHint={context?.userSkillsDir || '~/.claude/skills/'}
|
|
461
|
+
skills={userSkills}
|
|
462
|
+
loading={loading}
|
|
463
|
+
onEdit={s => setView({ kind: 'editor', scope: s.scope, name: s.name })}
|
|
464
|
+
onNew={() => setView({ kind: 'editor', scope: 'user', name: null })}
|
|
465
|
+
onRename={handleRename}
|
|
466
|
+
onDuplicate={handleDuplicate}
|
|
467
|
+
onDelete={handleDelete}
|
|
468
|
+
onSync={handleSyncTracking}
|
|
469
|
+
syncDisabled={syncing || !allowGithubImport}
|
|
470
|
+
/>
|
|
471
|
+
<SkillScopeSection
|
|
472
|
+
scope="project"
|
|
473
|
+
label={
|
|
474
|
+
context?.projectName ? `PROJECT · ${context.projectName}` : 'PROJECT'
|
|
475
|
+
}
|
|
476
|
+
pathHint={context?.projectSkillsDir || '<project>/.claude/skills/'}
|
|
477
|
+
skills={projectSkills}
|
|
478
|
+
loading={loading}
|
|
479
|
+
onEdit={s => setView({ kind: 'editor', scope: s.scope, name: s.name })}
|
|
480
|
+
onNew={() => setView({ kind: 'editor', scope: 'project', name: null })}
|
|
481
|
+
onRename={handleRename}
|
|
482
|
+
onDuplicate={handleDuplicate}
|
|
483
|
+
onDelete={handleDelete}
|
|
484
|
+
onSync={handleSyncTracking}
|
|
485
|
+
syncDisabled={syncing || !allowGithubImport}
|
|
486
|
+
/>
|
|
487
|
+
{prompt && (
|
|
488
|
+
<SkillPromptDialog
|
|
489
|
+
prompt={prompt}
|
|
490
|
+
existingNames={skills}
|
|
491
|
+
onCancel={() => setPrompt(null)}
|
|
492
|
+
onRename={commitRename}
|
|
493
|
+
onDuplicate={commitDuplicate}
|
|
494
|
+
/>
|
|
495
|
+
)}
|
|
496
|
+
{undo && (
|
|
497
|
+
<UndoToast
|
|
498
|
+
message={`Deleted "${undo.detail.name}"`}
|
|
499
|
+
onUndo={handleUndoDelete}
|
|
500
|
+
onDismiss={dismissUndo}
|
|
501
|
+
/>
|
|
502
|
+
)}
|
|
503
|
+
{importOpen && (
|
|
504
|
+
<GitHubImportDialog
|
|
505
|
+
onCancel={() => setImportOpen(false)}
|
|
506
|
+
onImported={async () => {
|
|
507
|
+
setImportOpen(false);
|
|
508
|
+
await refresh();
|
|
509
|
+
}}
|
|
510
|
+
/>
|
|
511
|
+
)}
|
|
512
|
+
</div>
|
|
513
|
+
);
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function GitHubImportDialog(props: {
|
|
517
|
+
onCancel: () => void;
|
|
518
|
+
onImported: () => Promise<void> | void;
|
|
519
|
+
}): JSX.Element {
|
|
520
|
+
type Step = 'url' | 'preview';
|
|
521
|
+
const [step, setStep] = useState<Step>('url');
|
|
522
|
+
const [url, setUrl] = useState('');
|
|
523
|
+
const [scope, setScope] = useState<SkillScope>('user');
|
|
524
|
+
const [preview, setPreview] = useState<ISkillImportPreview | null>(null);
|
|
525
|
+
const [nameOverride, setNameOverride] = useState('');
|
|
526
|
+
const [overwrite, setOverwrite] = useState(false);
|
|
527
|
+
const [tracksUpstream, setTracksUpstream] = useState(false);
|
|
528
|
+
const [busy, setBusy] = useState(false);
|
|
529
|
+
const [error, setError] = useState<string | null>(null);
|
|
530
|
+
|
|
531
|
+
useEscapeKey(props.onCancel);
|
|
532
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
533
|
+
useFocusTrap(formRef);
|
|
534
|
+
|
|
535
|
+
const effectiveName = (nameOverride.trim() || preview?.name || '').trim();
|
|
536
|
+
const nameValid = SKILL_NAME_PATTERN.test(effectiveName);
|
|
537
|
+
const collides =
|
|
538
|
+
preview !== null &&
|
|
539
|
+
((scope === 'user' && preview.existsInUserScope) ||
|
|
540
|
+
(scope === 'project' && preview.existsInProjectScope)) &&
|
|
541
|
+
effectiveName === preview.name;
|
|
542
|
+
|
|
543
|
+
const canFetchPreview = !busy && url.trim().length > 0;
|
|
544
|
+
const canInstall =
|
|
545
|
+
!busy && preview !== null && nameValid && (!collides || overwrite);
|
|
546
|
+
|
|
547
|
+
const handleFetchPreview = async (e: React.FormEvent) => {
|
|
548
|
+
e.preventDefault();
|
|
549
|
+
if (!canFetchPreview) {
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
setBusy(true);
|
|
553
|
+
setError(null);
|
|
554
|
+
try {
|
|
555
|
+
const p = await NBIAPI.previewSkillImport(url.trim());
|
|
556
|
+
setPreview(p);
|
|
557
|
+
setNameOverride('');
|
|
558
|
+
setOverwrite(false);
|
|
559
|
+
setStep('preview');
|
|
560
|
+
} catch (err: any) {
|
|
561
|
+
setError(err?.message ?? String(err));
|
|
562
|
+
} finally {
|
|
563
|
+
setBusy(false);
|
|
564
|
+
}
|
|
565
|
+
};
|
|
566
|
+
|
|
567
|
+
const handleInstall = async (e: React.FormEvent) => {
|
|
568
|
+
e.preventDefault();
|
|
569
|
+
if (!canInstall || !preview) {
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
setBusy(true);
|
|
573
|
+
setError(null);
|
|
574
|
+
try {
|
|
575
|
+
await NBIAPI.importSkill({
|
|
576
|
+
url: url.trim(),
|
|
577
|
+
scope,
|
|
578
|
+
name: effectiveName !== preview.name ? effectiveName : undefined,
|
|
579
|
+
overwrite: collides ? true : undefined,
|
|
580
|
+
tracksUpstream: tracksUpstream || undefined
|
|
581
|
+
});
|
|
582
|
+
await props.onImported();
|
|
583
|
+
} catch (err: any) {
|
|
584
|
+
setError(err?.message ?? String(err));
|
|
585
|
+
setBusy(false);
|
|
586
|
+
}
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
return (
|
|
590
|
+
<div
|
|
591
|
+
className="nbi-modal-backdrop"
|
|
592
|
+
onClick={props.onCancel}
|
|
593
|
+
role="presentation"
|
|
594
|
+
>
|
|
595
|
+
<form
|
|
596
|
+
ref={formRef}
|
|
597
|
+
className="nbi-modal-card"
|
|
598
|
+
role="dialog"
|
|
599
|
+
aria-modal="true"
|
|
600
|
+
aria-label="Import skill from GitHub"
|
|
601
|
+
onClick={e => e.stopPropagation()}
|
|
602
|
+
onSubmit={step === 'url' ? handleFetchPreview : handleInstall}
|
|
603
|
+
>
|
|
604
|
+
<div className="nbi-modal-title">Import skill from GitHub</div>
|
|
605
|
+
<div className="nbi-modal-body">
|
|
606
|
+
{step === 'url' && (
|
|
607
|
+
<>
|
|
608
|
+
<div className="nbi-form-field">
|
|
609
|
+
<label htmlFor="nbi-import-url">GitHub repo URL</label>
|
|
610
|
+
<input
|
|
611
|
+
id="nbi-import-url"
|
|
612
|
+
type="text"
|
|
613
|
+
autoFocus
|
|
614
|
+
value={url}
|
|
615
|
+
onChange={e => setUrl(e.target.value)}
|
|
616
|
+
placeholder="https://github.com/owner/repo or .../tree/main/path/to/skill"
|
|
617
|
+
/>
|
|
618
|
+
<div className="nbi-form-hint">
|
|
619
|
+
Link to the repo root, a branch, or a subdirectory containing{' '}
|
|
620
|
+
<code>SKILL.md</code>. Private repos work if{' '}
|
|
621
|
+
<code>GITHUB_TOKEN</code> is set or <code>gh auth login</code>{' '}
|
|
622
|
+
is configured on the Jupyter server.
|
|
623
|
+
</div>
|
|
624
|
+
</div>
|
|
625
|
+
<div className="nbi-form-field">
|
|
626
|
+
<label htmlFor="nbi-import-scope">Install into</label>
|
|
627
|
+
<select
|
|
628
|
+
id="nbi-import-scope"
|
|
629
|
+
value={scope}
|
|
630
|
+
onChange={e => setScope(e.target.value as SkillScope)}
|
|
631
|
+
>
|
|
632
|
+
<option value="user">User (available in all projects)</option>
|
|
633
|
+
<option value="project">Project (this project only)</option>
|
|
634
|
+
</select>
|
|
635
|
+
</div>
|
|
636
|
+
</>
|
|
637
|
+
)}
|
|
638
|
+
{step === 'preview' && preview && (
|
|
639
|
+
<>
|
|
640
|
+
<div className="nbi-import-preview">
|
|
641
|
+
<div className="nbi-import-preview-row">
|
|
642
|
+
<span className="nbi-import-preview-label">Name</span>
|
|
643
|
+
<span className="nbi-import-preview-value">
|
|
644
|
+
{preview.name}
|
|
645
|
+
</span>
|
|
646
|
+
</div>
|
|
647
|
+
{preview.description && (
|
|
648
|
+
<div className="nbi-import-preview-row">
|
|
649
|
+
<span className="nbi-import-preview-label">
|
|
650
|
+
Description
|
|
651
|
+
</span>
|
|
652
|
+
<span className="nbi-import-preview-value">
|
|
653
|
+
{preview.description}
|
|
654
|
+
</span>
|
|
655
|
+
</div>
|
|
656
|
+
)}
|
|
657
|
+
{preview.allowedTools.length > 0 && (
|
|
658
|
+
<div className="nbi-import-preview-row">
|
|
659
|
+
<span className="nbi-import-preview-label">
|
|
660
|
+
Allowed tools
|
|
661
|
+
</span>
|
|
662
|
+
<span className="nbi-import-preview-value">
|
|
663
|
+
{preview.allowedTools.join(', ')}
|
|
664
|
+
</span>
|
|
665
|
+
</div>
|
|
666
|
+
)}
|
|
667
|
+
<div className="nbi-import-preview-row">
|
|
668
|
+
<span className="nbi-import-preview-label">Files</span>
|
|
669
|
+
<span className="nbi-import-preview-value">
|
|
670
|
+
SKILL.md
|
|
671
|
+
{preview.files.length > 0 &&
|
|
672
|
+
` + ${preview.files.length} other${preview.files.length === 1 ? '' : 's'}`}
|
|
673
|
+
</span>
|
|
674
|
+
</div>
|
|
675
|
+
<div className="nbi-import-preview-row">
|
|
676
|
+
<span className="nbi-import-preview-label">Source</span>
|
|
677
|
+
<span className="nbi-import-preview-value nbi-import-preview-source">
|
|
678
|
+
{preview.canonicalUrl}
|
|
679
|
+
</span>
|
|
680
|
+
</div>
|
|
681
|
+
</div>
|
|
682
|
+
<div className="nbi-form-field">
|
|
683
|
+
<label htmlFor="nbi-import-name">
|
|
684
|
+
Name (optional override)
|
|
685
|
+
</label>
|
|
686
|
+
<input
|
|
687
|
+
id="nbi-import-name"
|
|
688
|
+
type="text"
|
|
689
|
+
value={nameOverride}
|
|
690
|
+
onChange={e => setNameOverride(e.target.value)}
|
|
691
|
+
placeholder={preview.name}
|
|
692
|
+
aria-invalid={
|
|
693
|
+
effectiveName.length > 0 && !nameValid ? true : undefined
|
|
694
|
+
}
|
|
695
|
+
/>
|
|
696
|
+
{effectiveName.length > 0 && !nameValid && (
|
|
697
|
+
<div className="nbi-form-field-error">
|
|
698
|
+
{SKILL_NAME_REQUIREMENT}
|
|
699
|
+
</div>
|
|
700
|
+
)}
|
|
701
|
+
</div>
|
|
702
|
+
{collides && (
|
|
703
|
+
<div className="nbi-form-field">
|
|
704
|
+
<label className="nbi-checkbox-label">
|
|
705
|
+
<input
|
|
706
|
+
type="checkbox"
|
|
707
|
+
checked={overwrite}
|
|
708
|
+
onChange={e => setOverwrite(e.target.checked)}
|
|
709
|
+
/>
|
|
710
|
+
Overwrite existing {scope} skill "{preview.name}"
|
|
711
|
+
</label>
|
|
712
|
+
</div>
|
|
713
|
+
)}
|
|
714
|
+
<div className="nbi-form-field">
|
|
715
|
+
<label className="nbi-checkbox-label">
|
|
716
|
+
<input
|
|
717
|
+
type="checkbox"
|
|
718
|
+
checked={tracksUpstream}
|
|
719
|
+
onChange={e => setTracksUpstream(e.target.checked)}
|
|
720
|
+
/>
|
|
721
|
+
Track upstream. Show a Sync button so I can re-pull this skill
|
|
722
|
+
from GitHub later
|
|
723
|
+
</label>
|
|
724
|
+
</div>
|
|
725
|
+
</>
|
|
726
|
+
)}
|
|
727
|
+
</div>
|
|
728
|
+
{error && (
|
|
729
|
+
<div className="nbi-skills-error" role="alert">
|
|
730
|
+
{error}
|
|
731
|
+
</div>
|
|
732
|
+
)}
|
|
733
|
+
<div className="nbi-modal-actions">
|
|
734
|
+
{step === 'preview' && (
|
|
735
|
+
<button
|
|
736
|
+
type="button"
|
|
737
|
+
className="jp-Dialog-button jp-mod-reject jp-mod-styled"
|
|
738
|
+
onClick={() => {
|
|
739
|
+
setStep('url');
|
|
740
|
+
setPreview(null);
|
|
741
|
+
setError(null);
|
|
742
|
+
}}
|
|
743
|
+
>
|
|
744
|
+
<div className="jp-Dialog-buttonLabel">Back</div>
|
|
745
|
+
</button>
|
|
746
|
+
)}
|
|
747
|
+
<button
|
|
748
|
+
type="button"
|
|
749
|
+
className="jp-Dialog-button jp-mod-reject jp-mod-styled"
|
|
750
|
+
onClick={props.onCancel}
|
|
751
|
+
>
|
|
752
|
+
<div className="jp-Dialog-buttonLabel">Cancel</div>
|
|
753
|
+
</button>
|
|
754
|
+
{step === 'url' ? (
|
|
755
|
+
<button
|
|
756
|
+
type="submit"
|
|
757
|
+
className="jp-Dialog-button jp-mod-accept jp-mod-styled"
|
|
758
|
+
disabled={!canFetchPreview}
|
|
759
|
+
>
|
|
760
|
+
<div className="jp-Dialog-buttonLabel">
|
|
761
|
+
{busy ? 'Fetching…' : 'Next'}
|
|
762
|
+
</div>
|
|
763
|
+
</button>
|
|
764
|
+
) : (
|
|
765
|
+
<button
|
|
766
|
+
type="submit"
|
|
767
|
+
className="jp-Dialog-button jp-mod-accept jp-mod-styled"
|
|
768
|
+
disabled={!canInstall}
|
|
769
|
+
>
|
|
770
|
+
<div className="jp-Dialog-buttonLabel">
|
|
771
|
+
{busy ? 'Installing…' : 'Install'}
|
|
772
|
+
</div>
|
|
773
|
+
</button>
|
|
774
|
+
)}
|
|
775
|
+
</div>
|
|
776
|
+
</form>
|
|
777
|
+
</div>
|
|
778
|
+
);
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function SkillScopeSection(props: {
|
|
782
|
+
scope: SkillScope;
|
|
783
|
+
label: string;
|
|
784
|
+
pathHint: string;
|
|
785
|
+
skills: ISkillSummary[];
|
|
786
|
+
loading: boolean;
|
|
787
|
+
onEdit: (skill: ISkillSummary) => void;
|
|
788
|
+
onNew: () => void;
|
|
789
|
+
onRename: (skill: ISkillSummary) => void;
|
|
790
|
+
onDuplicate: (skill: ISkillSummary) => void;
|
|
791
|
+
onDelete: (skill: ISkillSummary) => void;
|
|
792
|
+
onSync: (skill: ISkillSummary) => void;
|
|
793
|
+
syncDisabled: boolean;
|
|
794
|
+
}): JSX.Element {
|
|
795
|
+
return (
|
|
796
|
+
<div className="nbi-skills-section">
|
|
797
|
+
<div className="nbi-skills-section-caption" title={props.pathHint}>
|
|
798
|
+
{props.label} · {props.skills.length}
|
|
799
|
+
</div>
|
|
800
|
+
{props.loading && props.skills.length === 0 && (
|
|
801
|
+
<div className="nbi-skills-empty">Loading…</div>
|
|
802
|
+
)}
|
|
803
|
+
{!props.loading && props.skills.length === 0 && (
|
|
804
|
+
<div className="nbi-skills-empty">
|
|
805
|
+
<span>
|
|
806
|
+
No {props.scope} skills. They live in <code>{props.pathHint}</code>.
|
|
807
|
+
</span>
|
|
808
|
+
<button
|
|
809
|
+
className="jp-toast-button jp-mod-small jp-Button"
|
|
810
|
+
onClick={props.onNew}
|
|
811
|
+
>
|
|
812
|
+
<div className="jp-Dialog-buttonLabel">
|
|
813
|
+
+ New {props.scope} skill
|
|
814
|
+
</div>
|
|
815
|
+
</button>
|
|
816
|
+
</div>
|
|
817
|
+
)}
|
|
818
|
+
{props.skills.map(skill => (
|
|
819
|
+
<SkillRow
|
|
820
|
+
key={`${skill.scope}:${skill.name}`}
|
|
821
|
+
skill={skill}
|
|
822
|
+
onEdit={() => props.onEdit(skill)}
|
|
823
|
+
onRename={() => props.onRename(skill)}
|
|
824
|
+
onDuplicate={() => props.onDuplicate(skill)}
|
|
825
|
+
onDelete={() => props.onDelete(skill)}
|
|
826
|
+
onSync={() => props.onSync(skill)}
|
|
827
|
+
syncDisabled={props.syncDisabled}
|
|
828
|
+
/>
|
|
829
|
+
))}
|
|
830
|
+
</div>
|
|
831
|
+
);
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function ManagedBadge(props: { source?: string }): JSX.Element {
|
|
835
|
+
return (
|
|
836
|
+
<span
|
|
837
|
+
className="nbi-skill-managed-badge"
|
|
838
|
+
title={`Managed by org manifest (${props.source ?? ''})`}
|
|
839
|
+
>
|
|
840
|
+
Managed
|
|
841
|
+
</span>
|
|
842
|
+
);
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function TrackingBadge(props: { source: string; ref: string }): JSX.Element {
|
|
846
|
+
const refSuffix = props.ref ? ` (last sync: ${props.ref.slice(0, 7)})` : '';
|
|
847
|
+
return (
|
|
848
|
+
<span
|
|
849
|
+
className="nbi-skill-tracking-badge"
|
|
850
|
+
title={`Tracking upstream from ${props.source}${refSuffix}`}
|
|
851
|
+
>
|
|
852
|
+
Tracking
|
|
853
|
+
</span>
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
function SkillRow(props: {
|
|
858
|
+
skill: ISkillSummary;
|
|
859
|
+
onEdit: () => void;
|
|
860
|
+
onRename: () => void;
|
|
861
|
+
onDuplicate: () => void;
|
|
862
|
+
onDelete: () => void;
|
|
863
|
+
onSync: () => void;
|
|
864
|
+
syncDisabled: boolean;
|
|
865
|
+
}): JSX.Element {
|
|
866
|
+
const { skill } = props;
|
|
867
|
+
const stopAnd = (fn: () => void) => (e: React.MouseEvent) => {
|
|
868
|
+
e.stopPropagation();
|
|
869
|
+
fn();
|
|
870
|
+
};
|
|
871
|
+
// The wrapper div is no longer interactive. Nested interactive widgets
|
|
872
|
+
// (role=button on a div that contains <button> children) violate ARIA
|
|
873
|
+
// semantics and confuse screen readers and form serialisation. The
|
|
874
|
+
// visible Edit button is the canonical activation path; the wrapper
|
|
875
|
+
// keeps its click handler purely as a sighted-user convenience so a
|
|
876
|
+
// click anywhere on the row still opens the editor, but it's no longer
|
|
877
|
+
// in the keyboard tab order — that role belongs to the explicit
|
|
878
|
+
// buttons below.
|
|
879
|
+
return (
|
|
880
|
+
<div className="nbi-skill-row" onClick={props.onEdit}>
|
|
881
|
+
<div className="nbi-skill-row-main">
|
|
882
|
+
<div className="nbi-skill-row-name">
|
|
883
|
+
{skill.name}
|
|
884
|
+
{skill.managed && <ManagedBadge source={skill.managedSource} />}
|
|
885
|
+
{!skill.managed && skill.tracksUpstream && (
|
|
886
|
+
<TrackingBadge source={skill.source} ref={skill.trackingRef} />
|
|
887
|
+
)}
|
|
888
|
+
</div>
|
|
889
|
+
{skill.description && (
|
|
890
|
+
<div className="nbi-skill-row-description">{skill.description}</div>
|
|
891
|
+
)}
|
|
892
|
+
</div>
|
|
893
|
+
<div className="nbi-skill-row-actions" onClick={e => e.stopPropagation()}>
|
|
894
|
+
{!skill.managed && skill.tracksUpstream && (
|
|
895
|
+
<button
|
|
896
|
+
type="button"
|
|
897
|
+
className="nbi-icon-button"
|
|
898
|
+
aria-label="Sync skill from GitHub"
|
|
899
|
+
title="Sync from GitHub"
|
|
900
|
+
onClick={stopAnd(props.onSync)}
|
|
901
|
+
disabled={props.syncDisabled}
|
|
902
|
+
>
|
|
903
|
+
↻
|
|
904
|
+
</button>
|
|
905
|
+
)}
|
|
906
|
+
<button
|
|
907
|
+
type="button"
|
|
908
|
+
className="nbi-icon-button"
|
|
909
|
+
aria-label={skill.managed ? 'View skill' : 'Edit skill'}
|
|
910
|
+
title={skill.managed ? 'View (managed, read-only)' : 'Edit'}
|
|
911
|
+
onClick={stopAnd(props.onEdit)}
|
|
912
|
+
>
|
|
913
|
+
<VscEdit aria-hidden="true" />
|
|
914
|
+
</button>
|
|
915
|
+
{!skill.managed && (
|
|
916
|
+
<button
|
|
917
|
+
type="button"
|
|
918
|
+
className="nbi-icon-button"
|
|
919
|
+
aria-label="Rename skill"
|
|
920
|
+
title="Rename"
|
|
921
|
+
onClick={stopAnd(props.onRename)}
|
|
922
|
+
>
|
|
923
|
+
<span aria-hidden="true" className="nbi-icon-button-text">
|
|
924
|
+
Aa
|
|
925
|
+
</span>
|
|
926
|
+
</button>
|
|
927
|
+
)}
|
|
928
|
+
<button
|
|
929
|
+
type="button"
|
|
930
|
+
className="nbi-icon-button"
|
|
931
|
+
aria-label="Duplicate skill"
|
|
932
|
+
title="Duplicate"
|
|
933
|
+
onClick={stopAnd(props.onDuplicate)}
|
|
934
|
+
>
|
|
935
|
+
<VscCopy aria-hidden="true" />
|
|
936
|
+
</button>
|
|
937
|
+
{!skill.managed && (
|
|
938
|
+
<button
|
|
939
|
+
type="button"
|
|
940
|
+
className="nbi-icon-button danger"
|
|
941
|
+
aria-label="Delete skill"
|
|
942
|
+
title="Delete"
|
|
943
|
+
onClick={stopAnd(props.onDelete)}
|
|
944
|
+
>
|
|
945
|
+
<VscTrash aria-hidden="true" />
|
|
946
|
+
</button>
|
|
947
|
+
)}
|
|
948
|
+
</div>
|
|
949
|
+
</div>
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function SkillPromptDialog(props: {
|
|
954
|
+
prompt: Exclude<PromptMode, null>;
|
|
955
|
+
existingNames: ISkillSummary[];
|
|
956
|
+
onCancel: () => void;
|
|
957
|
+
onRename: (skill: ISkillSummary, newName: string) => Promise<void>;
|
|
958
|
+
onDuplicate: (
|
|
959
|
+
skill: ISkillSummary,
|
|
960
|
+
scope: SkillScope,
|
|
961
|
+
newName: string
|
|
962
|
+
) => Promise<void>;
|
|
963
|
+
}): JSX.Element {
|
|
964
|
+
const { prompt } = props;
|
|
965
|
+
const isRename = prompt.kind === 'rename';
|
|
966
|
+
const initialName = isRename
|
|
967
|
+
? prompt.skill.name
|
|
968
|
+
: `${prompt.skill.name}-copy`;
|
|
969
|
+
const initialScope: SkillScope = isRename
|
|
970
|
+
? prompt.skill.scope
|
|
971
|
+
: prompt.skill.scope === 'user'
|
|
972
|
+
? 'project'
|
|
973
|
+
: 'user';
|
|
974
|
+
const [name, setName] = useState(initialName);
|
|
975
|
+
const [scope, setScope] = useState<SkillScope>(initialScope);
|
|
976
|
+
const [error, setError] = useState<string | null>(null);
|
|
977
|
+
const [busy, setBusy] = useState(false);
|
|
978
|
+
|
|
979
|
+
useEscapeKey(props.onCancel);
|
|
980
|
+
const formRef = useRef<HTMLFormElement>(null);
|
|
981
|
+
useFocusTrap(formRef);
|
|
982
|
+
|
|
983
|
+
const trimmed = name.trim();
|
|
984
|
+
const nameValid = SKILL_NAME_PATTERN.test(trimmed);
|
|
985
|
+
const isUnchangedRename = isRename && trimmed === prompt.skill.name;
|
|
986
|
+
const conflict = props.existingNames.some(
|
|
987
|
+
s => s.scope === scope && s.name === trimmed && !isUnchangedRename
|
|
988
|
+
);
|
|
989
|
+
const canSubmit = !busy && nameValid && !conflict && !isUnchangedRename;
|
|
990
|
+
|
|
991
|
+
const title = isRename ? 'Rename skill' : 'Duplicate skill';
|
|
992
|
+
const submitLabel = isRename ? 'Rename' : 'Duplicate';
|
|
993
|
+
|
|
994
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
995
|
+
e.preventDefault();
|
|
996
|
+
if (!canSubmit) {
|
|
997
|
+
return;
|
|
998
|
+
}
|
|
999
|
+
setBusy(true);
|
|
1000
|
+
setError(null);
|
|
1001
|
+
try {
|
|
1002
|
+
if (isRename) {
|
|
1003
|
+
await props.onRename(prompt.skill, trimmed);
|
|
1004
|
+
} else {
|
|
1005
|
+
await props.onDuplicate(prompt.skill, scope, trimmed);
|
|
1006
|
+
}
|
|
1007
|
+
} catch (err: any) {
|
|
1008
|
+
setError(err?.message ?? String(err));
|
|
1009
|
+
setBusy(false);
|
|
1010
|
+
}
|
|
1011
|
+
};
|
|
1012
|
+
|
|
1013
|
+
return (
|
|
1014
|
+
<div
|
|
1015
|
+
className="nbi-modal-backdrop"
|
|
1016
|
+
onClick={props.onCancel}
|
|
1017
|
+
role="presentation"
|
|
1018
|
+
>
|
|
1019
|
+
<form
|
|
1020
|
+
ref={formRef}
|
|
1021
|
+
className="nbi-modal-card"
|
|
1022
|
+
role="dialog"
|
|
1023
|
+
aria-modal="true"
|
|
1024
|
+
aria-label={title}
|
|
1025
|
+
onClick={e => e.stopPropagation()}
|
|
1026
|
+
onSubmit={handleSubmit}
|
|
1027
|
+
>
|
|
1028
|
+
<div className="nbi-modal-title">{title}</div>
|
|
1029
|
+
<div className="nbi-modal-body">
|
|
1030
|
+
{!isRename && (
|
|
1031
|
+
<div className="nbi-form-field">
|
|
1032
|
+
<label htmlFor="nbi-dup-scope">Target scope</label>
|
|
1033
|
+
<select
|
|
1034
|
+
id="nbi-dup-scope"
|
|
1035
|
+
value={scope}
|
|
1036
|
+
onChange={e => setScope(e.target.value as SkillScope)}
|
|
1037
|
+
>
|
|
1038
|
+
<option value="user">User</option>
|
|
1039
|
+
<option value="project">Project</option>
|
|
1040
|
+
</select>
|
|
1041
|
+
</div>
|
|
1042
|
+
)}
|
|
1043
|
+
<div className="nbi-form-field">
|
|
1044
|
+
<label htmlFor="nbi-prompt-name">New name</label>
|
|
1045
|
+
<input
|
|
1046
|
+
id="nbi-prompt-name"
|
|
1047
|
+
type="text"
|
|
1048
|
+
autoFocus
|
|
1049
|
+
value={name}
|
|
1050
|
+
onChange={e => setName(e.target.value)}
|
|
1051
|
+
onKeyDown={e => {
|
|
1052
|
+
if (e.key === 'Escape') {
|
|
1053
|
+
e.preventDefault();
|
|
1054
|
+
props.onCancel();
|
|
1055
|
+
}
|
|
1056
|
+
}}
|
|
1057
|
+
aria-invalid={
|
|
1058
|
+
(!nameValid && trimmed.length > 0) || conflict
|
|
1059
|
+
? true
|
|
1060
|
+
: undefined
|
|
1061
|
+
}
|
|
1062
|
+
/>
|
|
1063
|
+
{trimmed.length > 0 && !nameValid && (
|
|
1064
|
+
<div className="nbi-form-field-error">
|
|
1065
|
+
{SKILL_NAME_REQUIREMENT}
|
|
1066
|
+
</div>
|
|
1067
|
+
)}
|
|
1068
|
+
{conflict && (
|
|
1069
|
+
<div className="nbi-form-field-error">
|
|
1070
|
+
A {scope} skill named "{trimmed}" already exists.
|
|
1071
|
+
</div>
|
|
1072
|
+
)}
|
|
1073
|
+
</div>
|
|
1074
|
+
</div>
|
|
1075
|
+
{error && (
|
|
1076
|
+
<div className="nbi-skills-error" role="alert">
|
|
1077
|
+
{error}
|
|
1078
|
+
</div>
|
|
1079
|
+
)}
|
|
1080
|
+
<div className="nbi-modal-actions">
|
|
1081
|
+
<button
|
|
1082
|
+
type="button"
|
|
1083
|
+
className="jp-Dialog-button jp-mod-reject jp-mod-styled"
|
|
1084
|
+
onClick={props.onCancel}
|
|
1085
|
+
>
|
|
1086
|
+
<div className="jp-Dialog-buttonLabel">Cancel</div>
|
|
1087
|
+
</button>
|
|
1088
|
+
<button
|
|
1089
|
+
type="submit"
|
|
1090
|
+
className="jp-Dialog-button jp-mod-accept jp-mod-styled"
|
|
1091
|
+
disabled={!canSubmit}
|
|
1092
|
+
>
|
|
1093
|
+
<div className="jp-Dialog-buttonLabel">
|
|
1094
|
+
{busy ? 'Working…' : submitLabel}
|
|
1095
|
+
</div>
|
|
1096
|
+
</button>
|
|
1097
|
+
</div>
|
|
1098
|
+
</form>
|
|
1099
|
+
</div>
|
|
1100
|
+
);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function UndoToast(props: {
|
|
1104
|
+
message: string;
|
|
1105
|
+
onUndo: () => void;
|
|
1106
|
+
onDismiss: () => void;
|
|
1107
|
+
}): JSX.Element {
|
|
1108
|
+
return (
|
|
1109
|
+
<div className="nbi-undo-toast" role="status">
|
|
1110
|
+
<span className="nbi-undo-toast-message">{props.message}</span>
|
|
1111
|
+
<button
|
|
1112
|
+
type="button"
|
|
1113
|
+
className="nbi-undo-toast-action"
|
|
1114
|
+
onClick={props.onUndo}
|
|
1115
|
+
>
|
|
1116
|
+
Undo
|
|
1117
|
+
</button>
|
|
1118
|
+
<button
|
|
1119
|
+
type="button"
|
|
1120
|
+
className="nbi-undo-toast-close"
|
|
1121
|
+
aria-label="Dismiss"
|
|
1122
|
+
onClick={props.onDismiss}
|
|
1123
|
+
>
|
|
1124
|
+
×
|
|
1125
|
+
</button>
|
|
1126
|
+
</div>
|
|
1127
|
+
);
|
|
1128
|
+
}
|
|
1129
|
+
|
|
1130
|
+
interface IFileBuffer {
|
|
1131
|
+
content: string;
|
|
1132
|
+
saved: string;
|
|
1133
|
+
loaded: boolean;
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
function SkillEditor(props: {
|
|
1137
|
+
scope: SkillScope;
|
|
1138
|
+
name: string | null;
|
|
1139
|
+
onClose: () => void;
|
|
1140
|
+
}): JSX.Element {
|
|
1141
|
+
const isNew = props.name === null;
|
|
1142
|
+
const [scope, setScope] = useState<SkillScope>(props.scope);
|
|
1143
|
+
const [name, setName] = useState(props.name ?? '');
|
|
1144
|
+
const [description, setDescription] = useState('');
|
|
1145
|
+
const [allowedTools, setAllowedTools] = useState<string[]>([]);
|
|
1146
|
+
const [savedMeta, setSavedMeta] = useState({
|
|
1147
|
+
description: '',
|
|
1148
|
+
allowedTools: [] as string[]
|
|
1149
|
+
});
|
|
1150
|
+
// Bundle files are lazy-loaded when the user switches to their tab (see the fetch effect
|
|
1151
|
+
// below). `loaded: false` means we know the file exists on disk but haven't fetched its
|
|
1152
|
+
// content yet. SKILL.md is always loaded eagerly in loadSkill().
|
|
1153
|
+
const [buffers, setBuffers] = useState<Map<string, IFileBuffer>>(
|
|
1154
|
+
new Map([[SKILL_ENTRY_FILE, { content: '', saved: '', loaded: true }]])
|
|
1155
|
+
);
|
|
1156
|
+
const orderedFileList = useMemo(
|
|
1157
|
+
() => [
|
|
1158
|
+
SKILL_ENTRY_FILE,
|
|
1159
|
+
...Array.from(buffers.keys())
|
|
1160
|
+
.filter(f => f !== SKILL_ENTRY_FILE)
|
|
1161
|
+
.sort()
|
|
1162
|
+
],
|
|
1163
|
+
[buffers]
|
|
1164
|
+
);
|
|
1165
|
+
const bundleOverflow = orderedFileList.length > BUNDLE_FILE_DISPLAY_LIMIT;
|
|
1166
|
+
const displayedFileList = bundleOverflow
|
|
1167
|
+
? [SKILL_ENTRY_FILE]
|
|
1168
|
+
: orderedFileList;
|
|
1169
|
+
const [activeFile, setActiveFile] = useState<string>(SKILL_ENTRY_FILE);
|
|
1170
|
+
// If a reload pushes the bundle over the threshold while a helper file is
|
|
1171
|
+
// selected, snap back to SKILL.md — otherwise the tab strip shows no active
|
|
1172
|
+
// tab and there's no way to navigate anywhere else.
|
|
1173
|
+
useEffect(() => {
|
|
1174
|
+
if (bundleOverflow && activeFile !== SKILL_ENTRY_FILE) {
|
|
1175
|
+
setActiveFile(SKILL_ENTRY_FILE);
|
|
1176
|
+
}
|
|
1177
|
+
}, [bundleOverflow, activeFile]);
|
|
1178
|
+
const [renaming, setRenaming] = useState<string | null>(null);
|
|
1179
|
+
const [renameDraft, setRenameDraft] = useState('');
|
|
1180
|
+
const [addFileDraft, setAddFileDraft] = useState('');
|
|
1181
|
+
const [loading, setLoading] = useState(!isNew);
|
|
1182
|
+
const [saving, setSaving] = useState(false);
|
|
1183
|
+
const [error, setError] = useState<string | null>(null);
|
|
1184
|
+
const [hasCreated, setHasCreated] = useState(false);
|
|
1185
|
+
const [managed, setManaged] = useState(false);
|
|
1186
|
+
const [managedSource, setManagedSource] = useState('');
|
|
1187
|
+
const [tracksUpstream, setTracksUpstream] = useState(false);
|
|
1188
|
+
const [trackingRef, setTrackingRef] = useState('');
|
|
1189
|
+
const [skillSource, setSkillSource] = useState('');
|
|
1190
|
+
const [togglingTracking, setTogglingTracking] = useState(false);
|
|
1191
|
+
const [rootPath, setRootPath] = useState('');
|
|
1192
|
+
const errorRef = useRef<HTMLDivElement>(null);
|
|
1193
|
+
|
|
1194
|
+
const effectiveName = isNew && !hasCreated ? name : (props.name ?? name);
|
|
1195
|
+
const effectiveIsNew = isNew && !hasCreated;
|
|
1196
|
+
|
|
1197
|
+
const loadSkill = async (s: SkillScope, n: string) => {
|
|
1198
|
+
const skill: ISkillDetail = await NBIAPI.readSkill(s, n);
|
|
1199
|
+
setDescription(skill.description);
|
|
1200
|
+
setAllowedTools(skill.allowedTools ?? []);
|
|
1201
|
+
setManaged(skill.managed);
|
|
1202
|
+
setManagedSource(skill.managedSource ?? '');
|
|
1203
|
+
setTracksUpstream(skill.tracksUpstream);
|
|
1204
|
+
setTrackingRef(skill.trackingRef ?? '');
|
|
1205
|
+
setSkillSource(skill.source ?? '');
|
|
1206
|
+
setRootPath(skill.rootPath ?? '');
|
|
1207
|
+
const skillMdBody = skill.body ?? '';
|
|
1208
|
+
setSavedMeta({
|
|
1209
|
+
description: skill.description,
|
|
1210
|
+
allowedTools: skill.allowedTools ?? []
|
|
1211
|
+
});
|
|
1212
|
+
const newBuffers = new Map<string, IFileBuffer>();
|
|
1213
|
+
newBuffers.set(SKILL_ENTRY_FILE, {
|
|
1214
|
+
content: skillMdBody,
|
|
1215
|
+
saved: skillMdBody,
|
|
1216
|
+
loaded: true
|
|
1217
|
+
});
|
|
1218
|
+
for (const file of skill.files ?? []) {
|
|
1219
|
+
if (file !== SKILL_ENTRY_FILE) {
|
|
1220
|
+
newBuffers.set(file, { content: '', saved: '', loaded: false });
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
setBuffers(newBuffers);
|
|
1224
|
+
setActiveFile(SKILL_ENTRY_FILE);
|
|
1225
|
+
};
|
|
1226
|
+
|
|
1227
|
+
useEffect(() => {
|
|
1228
|
+
if (isNew) {
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
let cancelled = false;
|
|
1232
|
+
(async () => {
|
|
1233
|
+
try {
|
|
1234
|
+
await loadSkill(props.scope, props.name as string);
|
|
1235
|
+
} catch (e: any) {
|
|
1236
|
+
if (!cancelled) {
|
|
1237
|
+
setError(e?.message ?? String(e));
|
|
1238
|
+
}
|
|
1239
|
+
} finally {
|
|
1240
|
+
if (!cancelled) {
|
|
1241
|
+
setLoading(false);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
})();
|
|
1245
|
+
return () => {
|
|
1246
|
+
cancelled = true;
|
|
1247
|
+
};
|
|
1248
|
+
}, [isNew, props.scope, props.name]);
|
|
1249
|
+
|
|
1250
|
+
useEffect(() => {
|
|
1251
|
+
if (isNew && !hasCreated) {
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
const onReload = () => {
|
|
1255
|
+
const skillName = props.name ?? (hasCreated ? name : null);
|
|
1256
|
+
if (!skillName) {
|
|
1257
|
+
return;
|
|
1258
|
+
}
|
|
1259
|
+
loadSkill(props.scope, skillName).catch((e: any) => {
|
|
1260
|
+
setError(e?.message ?? String(e));
|
|
1261
|
+
});
|
|
1262
|
+
};
|
|
1263
|
+
NBIAPI.skillsReloaded.connect(onReload);
|
|
1264
|
+
return () => {
|
|
1265
|
+
NBIAPI.skillsReloaded.disconnect(onReload);
|
|
1266
|
+
};
|
|
1267
|
+
}, [isNew, hasCreated, props.scope, props.name, name]);
|
|
1268
|
+
|
|
1269
|
+
useEffect(() => {
|
|
1270
|
+
if (error) {
|
|
1271
|
+
errorRef.current?.scrollIntoView({ block: 'nearest' });
|
|
1272
|
+
}
|
|
1273
|
+
}, [error]);
|
|
1274
|
+
|
|
1275
|
+
const metaDirty =
|
|
1276
|
+
description !== savedMeta.description ||
|
|
1277
|
+
allowedTools.length !== savedMeta.allowedTools.length ||
|
|
1278
|
+
allowedTools.some((t, i) => t !== savedMeta.allowedTools[i]);
|
|
1279
|
+
const fileDirty = (path: string): boolean => {
|
|
1280
|
+
const b = buffers.get(path);
|
|
1281
|
+
return b !== undefined && b.content !== b.saved;
|
|
1282
|
+
};
|
|
1283
|
+
const anyFileDirty = Array.from(buffers.keys()).some(fileDirty);
|
|
1284
|
+
const anyDirty = metaDirty || anyFileDirty;
|
|
1285
|
+
|
|
1286
|
+
const nameValid = SKILL_NAME_PATTERN.test(name);
|
|
1287
|
+
const descriptionValid = description.trim().length > 0;
|
|
1288
|
+
const canSave =
|
|
1289
|
+
!saving && !managed && (!effectiveIsNew || nameValid) && descriptionValid;
|
|
1290
|
+
|
|
1291
|
+
const updateBuffer = (path: string, content: string) => {
|
|
1292
|
+
setBuffers(prev => {
|
|
1293
|
+
const next = new Map(prev);
|
|
1294
|
+
const existing = next.get(path) ?? {
|
|
1295
|
+
content: '',
|
|
1296
|
+
saved: '',
|
|
1297
|
+
loaded: true
|
|
1298
|
+
};
|
|
1299
|
+
next.set(path, { ...existing, content });
|
|
1300
|
+
return next;
|
|
1301
|
+
});
|
|
1302
|
+
};
|
|
1303
|
+
|
|
1304
|
+
const currentBuffer = buffers.get(activeFile)?.content ?? '';
|
|
1305
|
+
|
|
1306
|
+
const handleSave = async () => {
|
|
1307
|
+
setError(null);
|
|
1308
|
+
if (effectiveIsNew && !nameValid) {
|
|
1309
|
+
setError(`Invalid name. ${SKILL_NAME_REQUIREMENT}`);
|
|
1310
|
+
return;
|
|
1311
|
+
}
|
|
1312
|
+
setSaving(true);
|
|
1313
|
+
try {
|
|
1314
|
+
if (effectiveIsNew) {
|
|
1315
|
+
const initialBody = buffers.get(SKILL_ENTRY_FILE)?.content ?? '';
|
|
1316
|
+
await NBIAPI.createSkill({
|
|
1317
|
+
scope,
|
|
1318
|
+
name,
|
|
1319
|
+
description,
|
|
1320
|
+
allowedTools,
|
|
1321
|
+
body: initialBody
|
|
1322
|
+
});
|
|
1323
|
+
const extraFiles = Array.from(buffers.entries()).filter(
|
|
1324
|
+
([p]) => p !== SKILL_ENTRY_FILE
|
|
1325
|
+
);
|
|
1326
|
+
for (const [path, buf] of extraFiles) {
|
|
1327
|
+
try {
|
|
1328
|
+
await NBIAPI.writeBundleFile(scope, name, path, buf.content);
|
|
1329
|
+
} catch (e: any) {
|
|
1330
|
+
setError(`${path}: ${e?.message ?? String(e)}`);
|
|
1331
|
+
}
|
|
1332
|
+
}
|
|
1333
|
+
setHasCreated(true);
|
|
1334
|
+
await loadSkill(scope, name);
|
|
1335
|
+
} else {
|
|
1336
|
+
const skillName = effectiveName;
|
|
1337
|
+
const dirtyFilePaths = Array.from(buffers.keys()).filter(
|
|
1338
|
+
p => p !== SKILL_ENTRY_FILE && fileDirty(p)
|
|
1339
|
+
);
|
|
1340
|
+
const skillMdBody = buffers.get(SKILL_ENTRY_FILE)?.content ?? '';
|
|
1341
|
+
const skillMdDirty = fileDirty(SKILL_ENTRY_FILE);
|
|
1342
|
+
const savedPaths = new Set<string>();
|
|
1343
|
+
const fileResults = await Promise.allSettled(
|
|
1344
|
+
dirtyFilePaths.map(p =>
|
|
1345
|
+
NBIAPI.writeBundleFile(scope, skillName, p, buffers.get(p)!.content)
|
|
1346
|
+
)
|
|
1347
|
+
);
|
|
1348
|
+
const errors: string[] = [];
|
|
1349
|
+
fileResults.forEach((result, i) => {
|
|
1350
|
+
const p = dirtyFilePaths[i];
|
|
1351
|
+
if (result.status === 'fulfilled') {
|
|
1352
|
+
savedPaths.add(p);
|
|
1353
|
+
} else {
|
|
1354
|
+
errors.push(`${p}: ${result.reason?.message ?? result.reason}`);
|
|
1355
|
+
}
|
|
1356
|
+
});
|
|
1357
|
+
let metaSaved = !(metaDirty || skillMdDirty);
|
|
1358
|
+
if (metaDirty || skillMdDirty) {
|
|
1359
|
+
try {
|
|
1360
|
+
await NBIAPI.updateSkill(scope, skillName, {
|
|
1361
|
+
description,
|
|
1362
|
+
allowedTools,
|
|
1363
|
+
body: skillMdBody
|
|
1364
|
+
});
|
|
1365
|
+
metaSaved = true;
|
|
1366
|
+
if (skillMdDirty) {
|
|
1367
|
+
savedPaths.add(SKILL_ENTRY_FILE);
|
|
1368
|
+
}
|
|
1369
|
+
} catch (e: any) {
|
|
1370
|
+
errors.push(e?.message ?? String(e));
|
|
1371
|
+
}
|
|
1372
|
+
}
|
|
1373
|
+
setBuffers(prev => {
|
|
1374
|
+
const next = new Map(prev);
|
|
1375
|
+
for (const path of savedPaths) {
|
|
1376
|
+
const b = next.get(path);
|
|
1377
|
+
if (b) {
|
|
1378
|
+
next.set(path, {
|
|
1379
|
+
content: b.content,
|
|
1380
|
+
saved: b.content,
|
|
1381
|
+
loaded: true
|
|
1382
|
+
});
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
return next;
|
|
1386
|
+
});
|
|
1387
|
+
if (metaSaved) {
|
|
1388
|
+
setSavedMeta({ description, allowedTools });
|
|
1389
|
+
}
|
|
1390
|
+
if (errors.length > 0) {
|
|
1391
|
+
setError(errors.join('\n'));
|
|
1392
|
+
}
|
|
1393
|
+
}
|
|
1394
|
+
} catch (e: any) {
|
|
1395
|
+
setError(e?.message ?? String(e));
|
|
1396
|
+
} finally {
|
|
1397
|
+
setSaving(false);
|
|
1398
|
+
}
|
|
1399
|
+
};
|
|
1400
|
+
|
|
1401
|
+
const handleBack = async () => {
|
|
1402
|
+
if (anyDirty) {
|
|
1403
|
+
const result = await showDialog({
|
|
1404
|
+
title: 'Discard unsaved changes?',
|
|
1405
|
+
body: 'This skill has unsaved changes. Discard them?',
|
|
1406
|
+
buttons: [
|
|
1407
|
+
Dialog.cancelButton({ label: 'Keep editing' }),
|
|
1408
|
+
Dialog.warnButton({ label: 'Discard' })
|
|
1409
|
+
]
|
|
1410
|
+
});
|
|
1411
|
+
if (!result.button.accept) {
|
|
1412
|
+
return;
|
|
1413
|
+
}
|
|
1414
|
+
}
|
|
1415
|
+
props.onClose();
|
|
1416
|
+
};
|
|
1417
|
+
|
|
1418
|
+
const handleSelectFile = (path: string) => {
|
|
1419
|
+
if (path === activeFile) {
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
setActiveFile(path);
|
|
1423
|
+
};
|
|
1424
|
+
|
|
1425
|
+
const handleAddFile = async () => {
|
|
1426
|
+
const relPath = addFileDraft.trim();
|
|
1427
|
+
if (!relPath) {
|
|
1428
|
+
return;
|
|
1429
|
+
}
|
|
1430
|
+
if (buffers.has(relPath)) {
|
|
1431
|
+
setError(`"${relPath}" already exists in this bundle.`);
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
try {
|
|
1435
|
+
if (!effectiveIsNew) {
|
|
1436
|
+
await NBIAPI.writeBundleFile(scope, effectiveName, relPath, '');
|
|
1437
|
+
}
|
|
1438
|
+
setBuffers(prev => {
|
|
1439
|
+
const next = new Map(prev);
|
|
1440
|
+
// For new skills, mark as dirty (saved='' vs content='' — equal, so not dirty;
|
|
1441
|
+
// but the initial empty file write happens on skill creation save).
|
|
1442
|
+
next.set(relPath, { content: '', saved: '', loaded: true });
|
|
1443
|
+
return next;
|
|
1444
|
+
});
|
|
1445
|
+
setActiveFile(relPath);
|
|
1446
|
+
setAddFileDraft('');
|
|
1447
|
+
} catch (e: any) {
|
|
1448
|
+
setError(e?.message ?? String(e));
|
|
1449
|
+
}
|
|
1450
|
+
};
|
|
1451
|
+
|
|
1452
|
+
const handleDeleteFile = async (path: string) => {
|
|
1453
|
+
const result = await showDialog({
|
|
1454
|
+
title: 'Delete file?',
|
|
1455
|
+
body: `"${path}" will be permanently deleted.`,
|
|
1456
|
+
buttons: [Dialog.cancelButton(), Dialog.warnButton({ label: 'Delete' })]
|
|
1457
|
+
});
|
|
1458
|
+
if (!result.button.accept) {
|
|
1459
|
+
return;
|
|
1460
|
+
}
|
|
1461
|
+
try {
|
|
1462
|
+
if (!effectiveIsNew) {
|
|
1463
|
+
await NBIAPI.deleteBundleFile(scope, effectiveName, path);
|
|
1464
|
+
}
|
|
1465
|
+
setBuffers(prev => {
|
|
1466
|
+
const next = new Map(prev);
|
|
1467
|
+
next.delete(path);
|
|
1468
|
+
return next;
|
|
1469
|
+
});
|
|
1470
|
+
if (activeFile === path) {
|
|
1471
|
+
setActiveFile(SKILL_ENTRY_FILE);
|
|
1472
|
+
}
|
|
1473
|
+
} catch (e: any) {
|
|
1474
|
+
setError(e?.message ?? String(e));
|
|
1475
|
+
}
|
|
1476
|
+
};
|
|
1477
|
+
|
|
1478
|
+
const handleBeginRename = (path: string) => {
|
|
1479
|
+
setRenaming(path);
|
|
1480
|
+
setRenameDraft(path);
|
|
1481
|
+
};
|
|
1482
|
+
|
|
1483
|
+
const handleCommitRename = async () => {
|
|
1484
|
+
if (renaming === null) {
|
|
1485
|
+
return;
|
|
1486
|
+
}
|
|
1487
|
+
const newPath = renameDraft.trim();
|
|
1488
|
+
if (!newPath || newPath === renaming) {
|
|
1489
|
+
setRenaming(null);
|
|
1490
|
+
return;
|
|
1491
|
+
}
|
|
1492
|
+
try {
|
|
1493
|
+
if (!effectiveIsNew) {
|
|
1494
|
+
await NBIAPI.renameBundleFile(scope, effectiveName, renaming, newPath);
|
|
1495
|
+
}
|
|
1496
|
+
setBuffers(prev => {
|
|
1497
|
+
const next = new Map(prev);
|
|
1498
|
+
const b = next.get(renaming!);
|
|
1499
|
+
if (b) {
|
|
1500
|
+
next.set(newPath, b);
|
|
1501
|
+
next.delete(renaming!);
|
|
1502
|
+
}
|
|
1503
|
+
return next;
|
|
1504
|
+
});
|
|
1505
|
+
if (activeFile === renaming) {
|
|
1506
|
+
setActiveFile(newPath);
|
|
1507
|
+
}
|
|
1508
|
+
setRenaming(null);
|
|
1509
|
+
setRenameDraft('');
|
|
1510
|
+
} catch (e: any) {
|
|
1511
|
+
setError(e?.message ?? String(e));
|
|
1512
|
+
setRenaming(null);
|
|
1513
|
+
}
|
|
1514
|
+
};
|
|
1515
|
+
|
|
1516
|
+
useEffect(() => {
|
|
1517
|
+
if (effectiveIsNew || activeFile === SKILL_ENTRY_FILE) {
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
const existing = buffers.get(activeFile);
|
|
1521
|
+
if (existing?.loaded) {
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
let cancelled = false;
|
|
1525
|
+
(async () => {
|
|
1526
|
+
try {
|
|
1527
|
+
const content = await NBIAPI.readBundleFile(
|
|
1528
|
+
scope,
|
|
1529
|
+
effectiveName,
|
|
1530
|
+
activeFile
|
|
1531
|
+
);
|
|
1532
|
+
if (cancelled) {
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
setBuffers(prev => {
|
|
1536
|
+
const existing = prev.get(activeFile);
|
|
1537
|
+
// Don't clobber user edits that happened while the fetch was in flight.
|
|
1538
|
+
if (existing?.loaded) {
|
|
1539
|
+
return prev;
|
|
1540
|
+
}
|
|
1541
|
+
const next = new Map(prev);
|
|
1542
|
+
next.set(activeFile, { content, saved: content, loaded: true });
|
|
1543
|
+
return next;
|
|
1544
|
+
});
|
|
1545
|
+
} catch (e: any) {
|
|
1546
|
+
if (!cancelled) {
|
|
1547
|
+
setError(e?.message ?? String(e));
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
})();
|
|
1551
|
+
return () => {
|
|
1552
|
+
cancelled = true;
|
|
1553
|
+
};
|
|
1554
|
+
}, [activeFile, effectiveIsNew, scope, effectiveName]);
|
|
1555
|
+
|
|
1556
|
+
const nameError =
|
|
1557
|
+
effectiveIsNew && name && !nameValid ? SKILL_NAME_REQUIREMENT : null;
|
|
1558
|
+
const descriptionError =
|
|
1559
|
+
!descriptionValid && (description.length > 0 || !effectiveIsNew)
|
|
1560
|
+
? 'Description is required.'
|
|
1561
|
+
: null;
|
|
1562
|
+
|
|
1563
|
+
const editingSkillMd = activeFile === SKILL_ENTRY_FILE;
|
|
1564
|
+
const bodyLanguageHint = editingSkillMd ? 'markdown' : activeFile;
|
|
1565
|
+
|
|
1566
|
+
return (
|
|
1567
|
+
<form
|
|
1568
|
+
className="nbi-skill-editor"
|
|
1569
|
+
onSubmit={e => {
|
|
1570
|
+
e.preventDefault();
|
|
1571
|
+
if (canSave && (effectiveIsNew || anyDirty)) {
|
|
1572
|
+
handleSave();
|
|
1573
|
+
}
|
|
1574
|
+
}}
|
|
1575
|
+
>
|
|
1576
|
+
<div className="nbi-skill-editor-header">
|
|
1577
|
+
<nav className="nbi-skill-editor-breadcrumb" aria-label="Breadcrumb">
|
|
1578
|
+
<button
|
|
1579
|
+
type="button"
|
|
1580
|
+
className="nbi-breadcrumb-link"
|
|
1581
|
+
onClick={handleBack}
|
|
1582
|
+
>
|
|
1583
|
+
Skills
|
|
1584
|
+
</button>
|
|
1585
|
+
<span className="nbi-breadcrumb-separator" aria-hidden="true">
|
|
1586
|
+
/
|
|
1587
|
+
</span>
|
|
1588
|
+
<span className="nbi-breadcrumb-current">
|
|
1589
|
+
{effectiveIsNew ? 'New skill' : effectiveName}
|
|
1590
|
+
{managed && <ManagedBadge source={managedSource} />}
|
|
1591
|
+
</span>
|
|
1592
|
+
</nav>
|
|
1593
|
+
<div className="nbi-skill-editor-actions">
|
|
1594
|
+
<button
|
|
1595
|
+
type="button"
|
|
1596
|
+
className="jp-Dialog-button jp-mod-reject jp-mod-styled"
|
|
1597
|
+
onClick={handleBack}
|
|
1598
|
+
>
|
|
1599
|
+
<div className="jp-Dialog-buttonLabel">
|
|
1600
|
+
{managed ? 'Close' : 'Cancel'}
|
|
1601
|
+
</div>
|
|
1602
|
+
</button>
|
|
1603
|
+
{!managed && (
|
|
1604
|
+
<button
|
|
1605
|
+
type="submit"
|
|
1606
|
+
className="jp-Dialog-button jp-mod-accept jp-mod-styled"
|
|
1607
|
+
disabled={!canSave || (!effectiveIsNew && !anyDirty)}
|
|
1608
|
+
>
|
|
1609
|
+
<div className="jp-Dialog-buttonLabel">
|
|
1610
|
+
{saving ? 'Saving…' : 'Save'}
|
|
1611
|
+
{anyDirty && (
|
|
1612
|
+
<span
|
|
1613
|
+
className="nbi-dirty-marker"
|
|
1614
|
+
aria-label="Unsaved changes"
|
|
1615
|
+
>
|
|
1616
|
+
{' '}
|
|
1617
|
+
•
|
|
1618
|
+
</span>
|
|
1619
|
+
)}
|
|
1620
|
+
</div>
|
|
1621
|
+
</button>
|
|
1622
|
+
)}
|
|
1623
|
+
</div>
|
|
1624
|
+
</div>
|
|
1625
|
+
{managed && (
|
|
1626
|
+
<div className="nbi-skills-managed-banner" role="note">
|
|
1627
|
+
This skill is managed by the organization manifest and is read-only.
|
|
1628
|
+
Changes will be overwritten on the next sync.
|
|
1629
|
+
</div>
|
|
1630
|
+
)}
|
|
1631
|
+
{!managed && !effectiveIsNew && skillSource && (
|
|
1632
|
+
<div className="nbi-skills-tracking-row">
|
|
1633
|
+
<label className="nbi-checkbox-label">
|
|
1634
|
+
<input
|
|
1635
|
+
type="checkbox"
|
|
1636
|
+
checked={tracksUpstream}
|
|
1637
|
+
disabled={togglingTracking || saving}
|
|
1638
|
+
onChange={async e => {
|
|
1639
|
+
const next = e.target.checked;
|
|
1640
|
+
// Optimistic flip so the checkbox tracks the click while
|
|
1641
|
+
// the PUT is in flight; rolled back on failure below.
|
|
1642
|
+
setTracksUpstream(next);
|
|
1643
|
+
setTogglingTracking(true);
|
|
1644
|
+
setError(null);
|
|
1645
|
+
try {
|
|
1646
|
+
const updated = await NBIAPI.updateSkill(
|
|
1647
|
+
scope,
|
|
1648
|
+
effectiveName,
|
|
1649
|
+
{ tracksUpstream: next }
|
|
1650
|
+
);
|
|
1651
|
+
setTracksUpstream(updated.tracksUpstream);
|
|
1652
|
+
setTrackingRef(updated.trackingRef);
|
|
1653
|
+
} catch (err: any) {
|
|
1654
|
+
// Roll back the optimistic flip so the UI matches the
|
|
1655
|
+
// server's state (the server kept the prior value).
|
|
1656
|
+
setTracksUpstream(!next);
|
|
1657
|
+
setError(err?.message ?? String(err));
|
|
1658
|
+
} finally {
|
|
1659
|
+
setTogglingTracking(false);
|
|
1660
|
+
}
|
|
1661
|
+
}}
|
|
1662
|
+
/>
|
|
1663
|
+
Track upstream
|
|
1664
|
+
</label>
|
|
1665
|
+
<span className="nbi-form-hint">
|
|
1666
|
+
Source: {skillSource}
|
|
1667
|
+
{tracksUpstream &&
|
|
1668
|
+
trackingRef &&
|
|
1669
|
+
` · last sync: ${trackingRef.slice(0, 7)}`}
|
|
1670
|
+
</span>
|
|
1671
|
+
</div>
|
|
1672
|
+
)}
|
|
1673
|
+
|
|
1674
|
+
{loading ? (
|
|
1675
|
+
<div className="nbi-skill-editor-loading">Loading skill…</div>
|
|
1676
|
+
) : (
|
|
1677
|
+
<>
|
|
1678
|
+
{error && (
|
|
1679
|
+
<div className="nbi-skills-error" role="alert" ref={errorRef}>
|
|
1680
|
+
{error}
|
|
1681
|
+
</div>
|
|
1682
|
+
)}
|
|
1683
|
+
|
|
1684
|
+
<div className="nbi-skill-editor-meta">
|
|
1685
|
+
<div className="nbi-form-row">
|
|
1686
|
+
<div className="nbi-form-row-inline">
|
|
1687
|
+
<div className="nbi-form-field">
|
|
1688
|
+
<label>Scope</label>
|
|
1689
|
+
<select
|
|
1690
|
+
value={scope}
|
|
1691
|
+
disabled={!effectiveIsNew}
|
|
1692
|
+
onChange={e => setScope(e.target.value as SkillScope)}
|
|
1693
|
+
>
|
|
1694
|
+
<option value="user">User</option>
|
|
1695
|
+
<option value="project">Project</option>
|
|
1696
|
+
</select>
|
|
1697
|
+
</div>
|
|
1698
|
+
<div className="nbi-form-field">
|
|
1699
|
+
<label>Name</label>
|
|
1700
|
+
<input
|
|
1701
|
+
type="text"
|
|
1702
|
+
value={name}
|
|
1703
|
+
disabled={!effectiveIsNew}
|
|
1704
|
+
onChange={e => setName(e.target.value)}
|
|
1705
|
+
placeholder="my-skill-name"
|
|
1706
|
+
aria-invalid={nameError ? true : undefined}
|
|
1707
|
+
/>
|
|
1708
|
+
{nameError && (
|
|
1709
|
+
<div className="nbi-form-field-error">{nameError}</div>
|
|
1710
|
+
)}
|
|
1711
|
+
</div>
|
|
1712
|
+
</div>
|
|
1713
|
+
{!effectiveIsNew && (
|
|
1714
|
+
<div className="nbi-form-hint">
|
|
1715
|
+
Scope and name are set at creation and can't be changed.
|
|
1716
|
+
Delete and recreate to change them.
|
|
1717
|
+
</div>
|
|
1718
|
+
)}
|
|
1719
|
+
</div>
|
|
1720
|
+
|
|
1721
|
+
<div className="nbi-form-field nbi-form-field-wide">
|
|
1722
|
+
<label>Description</label>
|
|
1723
|
+
<textarea
|
|
1724
|
+
rows={3}
|
|
1725
|
+
value={description}
|
|
1726
|
+
onChange={e => setDescription(e.target.value)}
|
|
1727
|
+
placeholder="What this skill does, shown to Claude when deciding whether to use it"
|
|
1728
|
+
aria-invalid={descriptionError ? true : undefined}
|
|
1729
|
+
required
|
|
1730
|
+
/>
|
|
1731
|
+
{descriptionError ? (
|
|
1732
|
+
<div className="nbi-form-field-error">{descriptionError}</div>
|
|
1733
|
+
) : (
|
|
1734
|
+
<div className="nbi-form-hint">
|
|
1735
|
+
Required. Claude uses this to decide when to apply the skill.
|
|
1736
|
+
</div>
|
|
1737
|
+
)}
|
|
1738
|
+
</div>
|
|
1739
|
+
|
|
1740
|
+
<div className="nbi-form-field nbi-form-field-wide">
|
|
1741
|
+
<label>Allowed tools</label>
|
|
1742
|
+
<AllowedToolsPicker
|
|
1743
|
+
value={allowedTools}
|
|
1744
|
+
onChange={setAllowedTools}
|
|
1745
|
+
/>
|
|
1746
|
+
<div className="nbi-form-hint">
|
|
1747
|
+
Quick-add pills cover common tools. Type patterns like{' '}
|
|
1748
|
+
<code>Bash(git:*)</code> or <code>Read(./docs/**)</code> for
|
|
1749
|
+
finer-grained permissions.
|
|
1750
|
+
</div>
|
|
1751
|
+
</div>
|
|
1752
|
+
</div>
|
|
1753
|
+
|
|
1754
|
+
{bundleOverflow && (
|
|
1755
|
+
<div className="nbi-form-hint">
|
|
1756
|
+
Bundle contains {orderedFileList.length} files — showing{' '}
|
|
1757
|
+
{SKILL_ENTRY_FILE} only. Edit supporting files directly on disk.
|
|
1758
|
+
{rootPath && (
|
|
1759
|
+
<>
|
|
1760
|
+
{' '}
|
|
1761
|
+
Path: <code>{rootPath}</code>
|
|
1762
|
+
</>
|
|
1763
|
+
)}
|
|
1764
|
+
</div>
|
|
1765
|
+
)}
|
|
1766
|
+
<BundleFileTabs
|
|
1767
|
+
files={displayedFileList}
|
|
1768
|
+
activeFile={activeFile}
|
|
1769
|
+
renaming={renaming}
|
|
1770
|
+
renameDraft={renameDraft}
|
|
1771
|
+
addFileDraft={addFileDraft}
|
|
1772
|
+
fileDirty={fileDirty}
|
|
1773
|
+
canAddFiles={!bundleOverflow}
|
|
1774
|
+
onSelect={handleSelectFile}
|
|
1775
|
+
onBeginRename={handleBeginRename}
|
|
1776
|
+
onCommitRename={handleCommitRename}
|
|
1777
|
+
onCancelRename={() => setRenaming(null)}
|
|
1778
|
+
onRenameDraftChange={setRenameDraft}
|
|
1779
|
+
onDelete={handleDeleteFile}
|
|
1780
|
+
onAddFileDraftChange={setAddFileDraft}
|
|
1781
|
+
onAddFile={handleAddFile}
|
|
1782
|
+
/>
|
|
1783
|
+
<div className="nbi-skill-editor-body">
|
|
1784
|
+
<div className="nbi-skill-editor-pane">
|
|
1785
|
+
<AutoGrowTextarea
|
|
1786
|
+
value={currentBuffer}
|
|
1787
|
+
onChange={v => updateBuffer(activeFile, v)}
|
|
1788
|
+
minRows={18}
|
|
1789
|
+
languageHint={bodyLanguageHint}
|
|
1790
|
+
/>
|
|
1791
|
+
</div>
|
|
1792
|
+
</div>
|
|
1793
|
+
</>
|
|
1794
|
+
)}
|
|
1795
|
+
</form>
|
|
1796
|
+
);
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function BundleFileTabs(props: {
|
|
1800
|
+
files: string[];
|
|
1801
|
+
activeFile: string;
|
|
1802
|
+
renaming: string | null;
|
|
1803
|
+
renameDraft: string;
|
|
1804
|
+
addFileDraft: string;
|
|
1805
|
+
fileDirty: (path: string) => boolean;
|
|
1806
|
+
canAddFiles: boolean;
|
|
1807
|
+
onSelect: (path: string) => void;
|
|
1808
|
+
onBeginRename: (path: string) => void;
|
|
1809
|
+
onCommitRename: () => void;
|
|
1810
|
+
onCancelRename: () => void;
|
|
1811
|
+
onRenameDraftChange: (v: string) => void;
|
|
1812
|
+
onDelete: (path: string) => void;
|
|
1813
|
+
onAddFileDraftChange: (v: string) => void;
|
|
1814
|
+
onAddFile: () => void;
|
|
1815
|
+
}): JSX.Element {
|
|
1816
|
+
const [openMenu, setOpenMenu] = useState<string | null>(null);
|
|
1817
|
+
const [adding, setAdding] = useState(false);
|
|
1818
|
+
|
|
1819
|
+
useEffect(() => {
|
|
1820
|
+
if (!openMenu) {
|
|
1821
|
+
return;
|
|
1822
|
+
}
|
|
1823
|
+
const onDocClick = () => setOpenMenu(null);
|
|
1824
|
+
document.addEventListener('click', onDocClick);
|
|
1825
|
+
return () => document.removeEventListener('click', onDocClick);
|
|
1826
|
+
}, [openMenu]);
|
|
1827
|
+
|
|
1828
|
+
const commitAdd = () => {
|
|
1829
|
+
if (props.addFileDraft.trim()) {
|
|
1830
|
+
props.onAddFile();
|
|
1831
|
+
}
|
|
1832
|
+
setAdding(false);
|
|
1833
|
+
};
|
|
1834
|
+
|
|
1835
|
+
return (
|
|
1836
|
+
<div className="nbi-skill-editor-tabs" role="tablist">
|
|
1837
|
+
{props.files.map(file => {
|
|
1838
|
+
const active = file === props.activeFile;
|
|
1839
|
+
const dirty = props.fileDirty(file);
|
|
1840
|
+
const isRenaming = props.renaming === file;
|
|
1841
|
+
const canModify = file !== SKILL_ENTRY_FILE;
|
|
1842
|
+
return (
|
|
1843
|
+
<div
|
|
1844
|
+
key={file}
|
|
1845
|
+
role="tab"
|
|
1846
|
+
aria-selected={active}
|
|
1847
|
+
className={`nbi-skill-editor-tab${active ? ' active' : ''}`}
|
|
1848
|
+
onClick={() => !isRenaming && props.onSelect(file)}
|
|
1849
|
+
>
|
|
1850
|
+
{isRenaming ? (
|
|
1851
|
+
<input
|
|
1852
|
+
type="text"
|
|
1853
|
+
autoFocus
|
|
1854
|
+
value={props.renameDraft}
|
|
1855
|
+
onChange={e => props.onRenameDraftChange(e.target.value)}
|
|
1856
|
+
onKeyDown={e => {
|
|
1857
|
+
if (e.key === 'Enter') {
|
|
1858
|
+
e.preventDefault();
|
|
1859
|
+
props.onCommitRename();
|
|
1860
|
+
} else if (e.key === 'Escape') {
|
|
1861
|
+
e.preventDefault();
|
|
1862
|
+
props.onCancelRename();
|
|
1863
|
+
}
|
|
1864
|
+
}}
|
|
1865
|
+
onBlur={props.onCancelRename}
|
|
1866
|
+
onClick={e => e.stopPropagation()}
|
|
1867
|
+
className="nbi-skill-editor-tab-rename-input"
|
|
1868
|
+
/>
|
|
1869
|
+
) : (
|
|
1870
|
+
<>
|
|
1871
|
+
<span className="nbi-skill-editor-tab-label">
|
|
1872
|
+
{file}
|
|
1873
|
+
{dirty && <span className="nbi-dirty-marker"> •</span>}
|
|
1874
|
+
</span>
|
|
1875
|
+
{canModify && (
|
|
1876
|
+
<div
|
|
1877
|
+
className="nbi-skill-editor-tab-kebab-wrap"
|
|
1878
|
+
onClick={e => e.stopPropagation()}
|
|
1879
|
+
>
|
|
1880
|
+
<button
|
|
1881
|
+
type="button"
|
|
1882
|
+
className="nbi-icon-button"
|
|
1883
|
+
aria-label={`Actions for ${file}`}
|
|
1884
|
+
aria-haspopup="menu"
|
|
1885
|
+
aria-expanded={openMenu === file}
|
|
1886
|
+
onClick={e => {
|
|
1887
|
+
e.stopPropagation();
|
|
1888
|
+
setOpenMenu(openMenu === file ? null : file);
|
|
1889
|
+
}}
|
|
1890
|
+
>
|
|
1891
|
+
⋯
|
|
1892
|
+
</button>
|
|
1893
|
+
{openMenu === file && (
|
|
1894
|
+
<div className="nbi-skill-editor-tab-menu" role="menu">
|
|
1895
|
+
<button
|
|
1896
|
+
type="button"
|
|
1897
|
+
role="menuitem"
|
|
1898
|
+
onClick={() => {
|
|
1899
|
+
setOpenMenu(null);
|
|
1900
|
+
props.onBeginRename(file);
|
|
1901
|
+
}}
|
|
1902
|
+
>
|
|
1903
|
+
Rename
|
|
1904
|
+
</button>
|
|
1905
|
+
<button
|
|
1906
|
+
type="button"
|
|
1907
|
+
role="menuitem"
|
|
1908
|
+
className="danger"
|
|
1909
|
+
onClick={() => {
|
|
1910
|
+
setOpenMenu(null);
|
|
1911
|
+
props.onDelete(file);
|
|
1912
|
+
}}
|
|
1913
|
+
>
|
|
1914
|
+
Delete
|
|
1915
|
+
</button>
|
|
1916
|
+
</div>
|
|
1917
|
+
)}
|
|
1918
|
+
</div>
|
|
1919
|
+
)}
|
|
1920
|
+
</>
|
|
1921
|
+
)}
|
|
1922
|
+
</div>
|
|
1923
|
+
);
|
|
1924
|
+
})}
|
|
1925
|
+
{props.canAddFiles &&
|
|
1926
|
+
(adding ? (
|
|
1927
|
+
<div className="nbi-skill-editor-tab adding">
|
|
1928
|
+
<input
|
|
1929
|
+
type="text"
|
|
1930
|
+
autoFocus
|
|
1931
|
+
placeholder="new-file.md"
|
|
1932
|
+
value={props.addFileDraft}
|
|
1933
|
+
onChange={e => props.onAddFileDraftChange(e.target.value)}
|
|
1934
|
+
onKeyDown={e => {
|
|
1935
|
+
if (e.key === 'Enter') {
|
|
1936
|
+
e.preventDefault();
|
|
1937
|
+
commitAdd();
|
|
1938
|
+
} else if (e.key === 'Escape') {
|
|
1939
|
+
e.preventDefault();
|
|
1940
|
+
props.onAddFileDraftChange('');
|
|
1941
|
+
setAdding(false);
|
|
1942
|
+
}
|
|
1943
|
+
}}
|
|
1944
|
+
onBlur={commitAdd}
|
|
1945
|
+
className="nbi-skill-editor-tab-rename-input"
|
|
1946
|
+
/>
|
|
1947
|
+
</div>
|
|
1948
|
+
) : (
|
|
1949
|
+
<button
|
|
1950
|
+
type="button"
|
|
1951
|
+
className="nbi-skill-editor-tab-add"
|
|
1952
|
+
aria-label="Add file"
|
|
1953
|
+
title="Add file"
|
|
1954
|
+
onClick={() => setAdding(true)}
|
|
1955
|
+
>
|
|
1956
|
+
+
|
|
1957
|
+
</button>
|
|
1958
|
+
))}
|
|
1959
|
+
</div>
|
|
1960
|
+
);
|
|
1961
|
+
}
|
|
1962
|
+
|
|
1963
|
+
function AllowedToolsPicker(props: {
|
|
1964
|
+
value: string[];
|
|
1965
|
+
onChange: (next: string[]) => void;
|
|
1966
|
+
}): JSX.Element {
|
|
1967
|
+
const [draft, setDraft] = useState('');
|
|
1968
|
+
|
|
1969
|
+
const commit = (raw: string) => {
|
|
1970
|
+
const parts = raw
|
|
1971
|
+
.split(',')
|
|
1972
|
+
.map(t => t.trim())
|
|
1973
|
+
.filter(t => t.length > 0 && !props.value.includes(t));
|
|
1974
|
+
if (parts.length === 0) {
|
|
1975
|
+
setDraft('');
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
props.onChange([...props.value, ...parts]);
|
|
1979
|
+
setDraft('');
|
|
1980
|
+
};
|
|
1981
|
+
|
|
1982
|
+
const toggle = (tool: string) => {
|
|
1983
|
+
if (props.value.includes(tool)) {
|
|
1984
|
+
props.onChange(props.value.filter(t => t !== tool));
|
|
1985
|
+
} else {
|
|
1986
|
+
props.onChange([...props.value, tool]);
|
|
1987
|
+
}
|
|
1988
|
+
};
|
|
1989
|
+
|
|
1990
|
+
const remove = (tool: string) => {
|
|
1991
|
+
props.onChange(props.value.filter(t => t !== tool));
|
|
1992
|
+
};
|
|
1993
|
+
|
|
1994
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
1995
|
+
if (e.key === 'Enter' || e.key === ',') {
|
|
1996
|
+
if (draft.trim()) {
|
|
1997
|
+
e.preventDefault();
|
|
1998
|
+
commit(draft);
|
|
1999
|
+
}
|
|
2000
|
+
} else if (e.key === 'Backspace' && !draft && props.value.length > 0) {
|
|
2001
|
+
remove(props.value[props.value.length - 1]);
|
|
2002
|
+
}
|
|
2003
|
+
};
|
|
2004
|
+
|
|
2005
|
+
return (
|
|
2006
|
+
<div>
|
|
2007
|
+
<div className="nbi-tools-picker-input">
|
|
2008
|
+
{props.value.map(tool => (
|
|
2009
|
+
<span key={tool} className="pill-item checked">
|
|
2010
|
+
{tool}
|
|
2011
|
+
<button
|
|
2012
|
+
onClick={() => remove(tool)}
|
|
2013
|
+
aria-label={`Remove ${tool}`}
|
|
2014
|
+
className="nbi-pill-remove"
|
|
2015
|
+
>
|
|
2016
|
+
×
|
|
2017
|
+
</button>
|
|
2018
|
+
</span>
|
|
2019
|
+
))}
|
|
2020
|
+
<input
|
|
2021
|
+
type="text"
|
|
2022
|
+
value={draft}
|
|
2023
|
+
onChange={e => setDraft(e.target.value)}
|
|
2024
|
+
onKeyDown={handleKeyDown}
|
|
2025
|
+
onBlur={() => draft.trim() && commit(draft)}
|
|
2026
|
+
placeholder={props.value.length === 0 ? 'e.g. Bash(git:*)' : ''}
|
|
2027
|
+
/>
|
|
2028
|
+
</div>
|
|
2029
|
+
<div className="nbi-tools-picker-suggestions">
|
|
2030
|
+
{COMMON_TOOLS.map(tool => (
|
|
2031
|
+
<span
|
|
2032
|
+
key={tool}
|
|
2033
|
+
className={`pill-item${props.value.includes(tool) ? ' checked' : ''}`}
|
|
2034
|
+
onClick={() => toggle(tool)}
|
|
2035
|
+
>
|
|
2036
|
+
{tool}
|
|
2037
|
+
</span>
|
|
2038
|
+
))}
|
|
2039
|
+
</div>
|
|
2040
|
+
</div>
|
|
2041
|
+
);
|
|
2042
|
+
}
|
|
2043
|
+
|
|
2044
|
+
function AutoGrowTextarea(props: {
|
|
2045
|
+
value: string;
|
|
2046
|
+
onChange: (v: string) => void;
|
|
2047
|
+
minRows: number;
|
|
2048
|
+
languageHint?: string;
|
|
2049
|
+
}): JSX.Element {
|
|
2050
|
+
const ref = useRef<HTMLTextAreaElement>(null);
|
|
2051
|
+
const lineHeightRef = useRef<number | null>(null);
|
|
2052
|
+
|
|
2053
|
+
useLayoutEffect(() => {
|
|
2054
|
+
const ta = ref.current;
|
|
2055
|
+
if (!ta) {
|
|
2056
|
+
return;
|
|
2057
|
+
}
|
|
2058
|
+
if (lineHeightRef.current === null) {
|
|
2059
|
+
const computed = window.getComputedStyle(ta);
|
|
2060
|
+
const parsed = parseFloat(computed.lineHeight);
|
|
2061
|
+
// CSS "normal" resolves to NaN here; 1.4× font-size is a standard approximation.
|
|
2062
|
+
lineHeightRef.current = Number.isNaN(parsed)
|
|
2063
|
+
? parseFloat(computed.fontSize) * 1.4
|
|
2064
|
+
: parsed;
|
|
2065
|
+
}
|
|
2066
|
+
ta.style.height = 'auto';
|
|
2067
|
+
const next = Math.max(
|
|
2068
|
+
ta.scrollHeight,
|
|
2069
|
+
props.minRows * lineHeightRef.current
|
|
2070
|
+
);
|
|
2071
|
+
ta.style.height = `${next}px`;
|
|
2072
|
+
}, [props.value, props.minRows]);
|
|
2073
|
+
|
|
2074
|
+
return (
|
|
2075
|
+
<textarea
|
|
2076
|
+
ref={ref}
|
|
2077
|
+
className="nbi-skill-editor-textarea"
|
|
2078
|
+
value={props.value}
|
|
2079
|
+
onChange={e => props.onChange(e.target.value)}
|
|
2080
|
+
spellCheck={false}
|
|
2081
|
+
data-language={props.languageHint}
|
|
2082
|
+
/>
|
|
2083
|
+
);
|
|
2084
|
+
}
|