@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.
Files changed (137) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +412 -0
  3. package/lib/api.d.ts +288 -0
  4. package/lib/api.js +927 -0
  5. package/lib/cell-output-bundle.d.ts +25 -0
  6. package/lib/cell-output-bundle.js +129 -0
  7. package/lib/cell-output-toolbar.d.ts +26 -0
  8. package/lib/cell-output-toolbar.js +188 -0
  9. package/lib/chat-progress-feedback.d.ts +3 -0
  10. package/lib/chat-progress-feedback.js +27 -0
  11. package/lib/chat-sidebar.d.ts +92 -0
  12. package/lib/chat-sidebar.js +3452 -0
  13. package/lib/command-ids.d.ts +39 -0
  14. package/lib/command-ids.js +44 -0
  15. package/lib/components/ask-user-question.d.ts +2 -0
  16. package/lib/components/ask-user-question.js +85 -0
  17. package/lib/components/checkbox.d.ts +2 -0
  18. package/lib/components/checkbox.js +30 -0
  19. package/lib/components/claude-mcp-panel.d.ts +2 -0
  20. package/lib/components/claude-mcp-panel.js +275 -0
  21. package/lib/components/claude-mcp-paste.d.ts +7 -0
  22. package/lib/components/claude-mcp-paste.js +104 -0
  23. package/lib/components/claude-session-picker.d.ts +8 -0
  24. package/lib/components/claude-session-picker.js +127 -0
  25. package/lib/components/form-dialog.d.ts +25 -0
  26. package/lib/components/form-dialog.js +35 -0
  27. package/lib/components/launcher-picker.d.ts +6 -0
  28. package/lib/components/launcher-picker.js +135 -0
  29. package/lib/components/mcp-util.d.ts +2 -0
  30. package/lib/components/mcp-util.js +37 -0
  31. package/lib/components/notebook-generation-popover.d.ts +7 -0
  32. package/lib/components/notebook-generation-popover.js +60 -0
  33. package/lib/components/pill.d.ts +2 -0
  34. package/lib/components/pill.js +5 -0
  35. package/lib/components/plugins-panel.d.ts +3 -0
  36. package/lib/components/plugins-panel.js +466 -0
  37. package/lib/components/settings-panel.d.ts +11 -0
  38. package/lib/components/settings-panel.js +742 -0
  39. package/lib/components/skills-panel.d.ts +2 -0
  40. package/lib/components/skills-panel.js +1264 -0
  41. package/lib/handler.d.ts +8 -0
  42. package/lib/handler.js +36 -0
  43. package/lib/icons.d.ts +45 -0
  44. package/lib/icons.js +54 -0
  45. package/lib/index.d.ts +8 -0
  46. package/lib/index.js +2079 -0
  47. package/lib/markdown-renderer.d.ts +10 -0
  48. package/lib/markdown-renderer.js +64 -0
  49. package/lib/notebook-generation-toolbar.d.ts +16 -0
  50. package/lib/notebook-generation-toolbar.js +197 -0
  51. package/lib/notebook-generation.d.ts +8 -0
  52. package/lib/notebook-generation.js +12 -0
  53. package/lib/open-file-refresh-watcher-env.d.ts +4 -0
  54. package/lib/open-file-refresh-watcher-env.js +33 -0
  55. package/lib/open-file-refresh-watcher.d.ts +97 -0
  56. package/lib/open-file-refresh-watcher.js +190 -0
  57. package/lib/shell-utils.d.ts +6 -0
  58. package/lib/shell-utils.js +9 -0
  59. package/lib/task-target-notebook.d.ts +2 -0
  60. package/lib/task-target-notebook.js +28 -0
  61. package/lib/terminal-drag-format.d.ts +9 -0
  62. package/lib/terminal-drag-format.js +23 -0
  63. package/lib/terminal-drag.d.ts +12 -0
  64. package/lib/terminal-drag.js +268 -0
  65. package/lib/tokens.d.ts +149 -0
  66. package/lib/tokens.js +88 -0
  67. package/lib/tour/tour-anchors.d.ts +18 -0
  68. package/lib/tour/tour-anchors.js +18 -0
  69. package/lib/tour/tour-config.d.ts +66 -0
  70. package/lib/tour/tour-config.js +99 -0
  71. package/lib/tour/tour-defaults.json +58 -0
  72. package/lib/tour/tour-events.d.ts +19 -0
  73. package/lib/tour/tour-events.js +30 -0
  74. package/lib/tour/tour-overlay.d.ts +6 -0
  75. package/lib/tour/tour-overlay.js +350 -0
  76. package/lib/tour/tour-state.d.ts +20 -0
  77. package/lib/tour/tour-state.js +81 -0
  78. package/lib/tour/tour-steps.d.ts +33 -0
  79. package/lib/tour/tour-steps.js +216 -0
  80. package/lib/utils.d.ts +53 -0
  81. package/lib/utils.js +385 -0
  82. package/package.json +258 -0
  83. package/schema/plugin.json +42 -0
  84. package/src/api.ts +1424 -0
  85. package/src/cell-output-bundle.ts +176 -0
  86. package/src/cell-output-toolbar.ts +232 -0
  87. package/src/chat-progress-feedback.ts +35 -0
  88. package/src/chat-sidebar.tsx +5147 -0
  89. package/src/command-ids.ts +67 -0
  90. package/src/components/ask-user-question.tsx +151 -0
  91. package/src/components/checkbox.tsx +62 -0
  92. package/src/components/claude-mcp-panel.tsx +543 -0
  93. package/src/components/claude-mcp-paste.ts +132 -0
  94. package/src/components/claude-session-picker.tsx +214 -0
  95. package/src/components/form-dialog.tsx +75 -0
  96. package/src/components/launcher-picker.tsx +237 -0
  97. package/src/components/mcp-util.ts +53 -0
  98. package/src/components/notebook-generation-popover.tsx +127 -0
  99. package/src/components/pill.tsx +15 -0
  100. package/src/components/plugins-panel.tsx +774 -0
  101. package/src/components/settings-panel.tsx +1631 -0
  102. package/src/components/skills-panel.tsx +2084 -0
  103. package/src/handler.ts +51 -0
  104. package/src/icons.ts +71 -0
  105. package/src/index.ts +2583 -0
  106. package/src/markdown-renderer.tsx +153 -0
  107. package/src/notebook-generation-toolbar.tsx +281 -0
  108. package/src/notebook-generation.ts +23 -0
  109. package/src/open-file-refresh-watcher-env.ts +52 -0
  110. package/src/open-file-refresh-watcher.ts +260 -0
  111. package/src/shell-utils.ts +10 -0
  112. package/src/svg.d.ts +4 -0
  113. package/src/task-target-notebook.ts +37 -0
  114. package/src/terminal-drag-format.ts +29 -0
  115. package/src/terminal-drag.ts +382 -0
  116. package/src/tokens.ts +171 -0
  117. package/src/tour/tour-anchors.ts +21 -0
  118. package/src/tour/tour-config.ts +160 -0
  119. package/src/tour/tour-events.ts +34 -0
  120. package/src/tour/tour-overlay.tsx +474 -0
  121. package/src/tour/tour-state.ts +87 -0
  122. package/src/tour/tour-steps.ts +281 -0
  123. package/src/utils.ts +455 -0
  124. package/style/base.css +3238 -0
  125. package/style/icons/cell-toolbar-bug.svg +5 -0
  126. package/style/icons/cell-toolbar-chat.svg +5 -0
  127. package/style/icons/cell-toolbar-sparkle.svg +5 -0
  128. package/style/icons/claude.svg +1 -0
  129. package/style/icons/copilot-warning.svg +1 -0
  130. package/style/icons/copilot.svg +1 -0
  131. package/style/icons/copy.svg +1 -0
  132. package/style/icons/openai.svg +1 -0
  133. package/style/icons/opencode.svg +1 -0
  134. package/style/icons/sparkles-warning.svg +5 -0
  135. package/style/icons/sparkles.svg +1 -0
  136. package/style/index.css +1 -0
  137. 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
+ }