prompt-skill-armory 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,614 @@
1
+ /**
2
+ * Prompt-SkillArmory management page.
3
+ *
4
+ * Three tabs within the settings dialog's fixed width: Prompts / Skills /
5
+ * Agent Presets. The Skills tab is the single home for skills — both the ones
6
+ * installed through this panel and the ones scanned from the local skill
7
+ * roots — merged into one list with full management (add / edit / toggle /
8
+ * remove / invoke hint). A CLI entry box offers direct command installation.
9
+ * @module @deepseek-ai/dsh-client-ui-switchblade
10
+ */
11
+
12
+ import { useEffect, useState } from 'react'
13
+ import type { CSSProperties, JSX } from 'react'
14
+ import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
15
+ import type { SwitchbladeKey } from './locales.ts'
16
+ import type { SwitchbladeSectionInjected, SwitchbladeSectionState } from './store.ts'
17
+
18
+ export type { SwitchbladeSectionInjected } from './store.ts'
19
+
20
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
21
+ interface LocaleNamespaceMap {
22
+ /** Prompt-SkillArmory page copy. */
23
+ 'settings.switchblade': SwitchbladeKey
24
+ }
25
+ }
26
+
27
+ /** Full component props: settings-section runtime + locale + injected face. */
28
+ export type SwitchbladeSectionProps =
29
+ PropsRuntime<'settings.section'>
30
+ & PropsLocale<'settings.switchblade'>
31
+ & InjectFace<SwitchbladeSectionInjected>
32
+
33
+ const PHOSPHOR = '#00ff9c'
34
+ const DAMNED = '#ff2b4b'
35
+ const AMBER = '#ffb000'
36
+ const GRAY = '#0f3d2c'
37
+
38
+ const CSS: Record<string, CSSProperties> = {
39
+ root: {
40
+ fontFamily: "'JetBrains Mono','IBM Plex Mono',ui-monospace,monospace",
41
+ background: '#04070a',
42
+ color: PHOSPHOR,
43
+ padding: '16px',
44
+ border: `1px solid ${GRAY}`,
45
+ boxShadow: 'inset 0 0 40px rgba(0,255,156,.06), 0 0 18px rgba(0,255,156,.15)',
46
+ borderRadius: '2px',
47
+ width: '100%',
48
+ boxSizing: 'border-box' as const,
49
+ },
50
+ head: {
51
+ display: 'flex',
52
+ alignItems: 'center',
53
+ justifyContent: 'space-between',
54
+ borderBottom: `1px solid ${GRAY}`,
55
+ paddingBottom: '8px',
56
+ marginBottom: '8px',
57
+ },
58
+ title: {
59
+ fontSize: '14px',
60
+ fontWeight: 700,
61
+ letterSpacing: '1px',
62
+ textShadow: `0 0 8px ${PHOSPHOR}`,
63
+ display: 'flex',
64
+ alignItems: 'center',
65
+ gap: '6px',
66
+ },
67
+ titleAccent: {
68
+ color: DAMNED,
69
+ textShadow: `0 0 8px ${DAMNED}`,
70
+ },
71
+ tabs: {
72
+ display: 'flex',
73
+ gap: '4px',
74
+ borderBottom: `1px solid ${GRAY}`,
75
+ marginBottom: '12px',
76
+ flexWrap: 'wrap' as const,
77
+ },
78
+ tab: {
79
+ background: 'transparent',
80
+ border: `1px solid transparent`,
81
+ borderBottom: 'none',
82
+ color: '#5fb08c',
83
+ font: 'inherit',
84
+ fontSize: '12px',
85
+ letterSpacing: '0.5px',
86
+ padding: '6px 10px',
87
+ cursor: 'pointer',
88
+ },
89
+ tabActive: {
90
+ color: PHOSPHOR,
91
+ borderColor: GRAY,
92
+ background: 'rgba(0,40,24,.1)',
93
+ textShadow: `0 0 6px ${PHOSPHOR}`,
94
+ },
95
+ columns: {
96
+ display: 'grid',
97
+ gridTemplateColumns: 'repeat(2, minmax(0, 1fr))',
98
+ gap: '12px',
99
+ alignItems: 'start',
100
+ },
101
+ column: {
102
+ display: 'flex',
103
+ flexDirection: 'column' as const,
104
+ gap: '10px',
105
+ minWidth: '0',
106
+ },
107
+ colHeader: {
108
+ fontSize: '12px',
109
+ fontWeight: 700,
110
+ letterSpacing: '1px',
111
+ color: AMBER,
112
+ borderBottom: `1px solid ${GRAY}`,
113
+ paddingBottom: '6px',
114
+ marginBottom: '2px',
115
+ },
116
+ card: {
117
+ border: `1px solid ${GRAY}`,
118
+ background: 'rgba(0,40,24,.08)',
119
+ padding: '8px 10px',
120
+ },
121
+ cardTop: {
122
+ display: 'flex',
123
+ alignItems: 'center',
124
+ justifyContent: 'space-between',
125
+ gap: '8px',
126
+ },
127
+ name: {
128
+ fontSize: '12px',
129
+ fontWeight: 700,
130
+ wordBreak: 'break-all' as const,
131
+ },
132
+ badge: {
133
+ fontSize: '9px',
134
+ letterSpacing: '1px',
135
+ padding: '2px 6px',
136
+ border: '1px solid currentColor',
137
+ flex: 'none',
138
+ },
139
+ badgeEnabled: { color: PHOSPHOR },
140
+ badgeDisabled: { color: DAMNED },
141
+ badgeInstalled: { color: AMBER },
142
+ desc: {
143
+ fontSize: '11px',
144
+ color: '#5fb08c',
145
+ marginTop: '4px',
146
+ },
147
+ invokeHint: {
148
+ fontSize: '10px',
149
+ color: '#3f8f6a',
150
+ marginTop: '4px',
151
+ fontStyle: 'italic',
152
+ },
153
+ empty: {
154
+ fontSize: '11px',
155
+ color: GRAY,
156
+ padding: '8px 0',
157
+ },
158
+ error: {
159
+ color: DAMNED,
160
+ fontSize: '11px',
161
+ padding: '8px 0',
162
+ },
163
+ refreshBtn: {
164
+ background: 'transparent',
165
+ border: `1px solid ${GRAY}`,
166
+ color: PHOSPHOR,
167
+ font: 'inherit',
168
+ fontSize: '11px',
169
+ letterSpacing: '2px',
170
+ textTransform: 'uppercase' as const,
171
+ padding: '4px 10px',
172
+ cursor: 'pointer',
173
+ },
174
+ actionBtn: {
175
+ background: 'transparent',
176
+ border: `1px solid ${GRAY}`,
177
+ color: PHOSPHOR,
178
+ font: 'inherit',
179
+ fontSize: '10px',
180
+ letterSpacing: '1px',
181
+ padding: '2px 8px',
182
+ cursor: 'pointer',
183
+ marginTop: '6px',
184
+ },
185
+ dangerBtn: {
186
+ borderColor: DAMNED,
187
+ color: DAMNED,
188
+ },
189
+ form: {
190
+ display: 'flex',
191
+ flexDirection: 'column' as const,
192
+ gap: '6px',
193
+ marginBottom: '10px',
194
+ padding: '10px',
195
+ border: `1px solid ${GRAY}`,
196
+ background: 'rgba(0,40,24,.05)',
197
+ },
198
+ input: {
199
+ background: '#04070a',
200
+ border: `1px solid ${GRAY}`,
201
+ color: PHOSPHOR,
202
+ font: 'inherit',
203
+ fontSize: '11px',
204
+ padding: '6px 8px',
205
+ },
206
+ textarea: {
207
+ background: '#04070a',
208
+ border: `1px solid ${GRAY}`,
209
+ color: PHOSPHOR,
210
+ font: 'inherit',
211
+ fontSize: '11px',
212
+ padding: '6px 8px',
213
+ minHeight: '80px',
214
+ resize: 'vertical' as const,
215
+ },
216
+ actions: {
217
+ display: 'flex',
218
+ gap: '6px',
219
+ flexWrap: 'wrap' as const,
220
+ },
221
+ hint: {
222
+ color: GRAY,
223
+ fontSize: '9px',
224
+ letterSpacing: '1px',
225
+ },
226
+ scrollBox: {
227
+ maxHeight: '520px',
228
+ overflowY: 'auto' as const,
229
+ display: 'flex',
230
+ flexDirection: 'column' as const,
231
+ gap: '8px',
232
+ paddingRight: '4px',
233
+ },
234
+ searchInput: {
235
+ background: '#04070a',
236
+ border: `1px solid ${GRAY}`,
237
+ color: '#5fb08c',
238
+ font: 'inherit',
239
+ fontSize: '10px',
240
+ padding: '5px 8px',
241
+ width: '100%',
242
+ boxSizing: 'border-box' as const,
243
+ },
244
+ fileBtn: {
245
+ background: 'transparent',
246
+ border: `1px dashed ${GRAY}`,
247
+ color: '#5fb08c',
248
+ font: 'inherit',
249
+ fontSize: '10px',
250
+ letterSpacing: '1px',
251
+ padding: '8px',
252
+ cursor: 'pointer',
253
+ textAlign: 'center' as const,
254
+ },
255
+ cliBox: {
256
+ border: `1px dashed ${AMBER}`,
257
+ padding: '8px 10px',
258
+ fontSize: '10px',
259
+ color: '#5fb08c',
260
+ background: 'rgba(255,176,0,.04)',
261
+ },
262
+ versionBadge: {
263
+ fontSize: '10px',
264
+ fontWeight: 700,
265
+ letterSpacing: '1px',
266
+ color: PHOSPHOR,
267
+ border: `1px solid ${PHOSPHOR}`,
268
+ borderRadius: '3px',
269
+ padding: '1px 6px',
270
+ marginLeft: '6px',
271
+ background: 'rgba(0,255,156,.08)',
272
+ textShadow: `0 0 6px ${PHOSPHOR}`,
273
+ flex: 'none',
274
+ },
275
+ }
276
+
277
+ /** Open-book glyph. */
278
+ function BookIcon({ size = 16 }: { size?: number }): JSX.Element {
279
+ return (
280
+ <svg width={size} height={size} viewBox="0 0 16 16" fill="none" style={{ flex: 'none' }} aria-hidden="true">
281
+ <path d="M7.5 3.2C6.2 2.4 4.6 2.2 2.8 2.5c-.5.08-.8.5-.8 1v7.6c0 .4.3.7.7.7 1.7-.2 3.2.1 4.8 1V3.2z" fill="currentColor" opacity="0.55" />
282
+ <path d="M8.5 3.2c1.3-.8 2.9-1 4.7-.7.5.08.8.5.8 1v7.6c0 .4-.3.7-.7.7-1.7-.2-3.2.1-4.8 1V3.2z" fill="currentColor" opacity="0.85" />
283
+ <path d="M8 3.2v10.3" stroke="currentColor" strokeWidth="0.7" />
284
+ </svg>
285
+ )
286
+ }
287
+
288
+ type TabKey = 'prompts' | 'skills' | 'presets'
289
+
290
+ /** Bump with every release; keep in sync with package.json version + CHANGELOG. */
291
+ const ARMORY_VERSION = '0.4.1'
292
+
293
+ /** Render the Prompt-SkillArmory management page. */
294
+ export function SwitchbladeSection(props: SwitchbladeSectionProps): JSX.Element {
295
+ const {
296
+ useSwitchblade, t, load,
297
+ setDefaultPreset, addPrompt, updatePrompt, setPromptEnabled, setDefaultPrompt, deletePrompt,
298
+ installSkill, updateSkill, setSkillEnabled, uninstallSkill,
299
+ } = props
300
+ const state = useSwitchblade((snapshot: SwitchbladeSectionState) => snapshot)
301
+ const [promptName, setPromptName] = useState('')
302
+ const [promptDesc, setPromptDesc] = useState('')
303
+ const [promptContent, setPromptContent] = useState('')
304
+ const [skillName, setSkillName] = useState('')
305
+ const [skillDesc, setSkillDesc] = useState('')
306
+ const [skillContent, setSkillContent] = useState('')
307
+ const [busy, setBusy] = useState(false)
308
+ const [pickedFile, setPickedFile] = useState('')
309
+ const [promptQuery, setPromptQuery] = useState('')
310
+ const [skillQuery, setSkillQuery] = useState('')
311
+ const [presetQuery, setPresetQuery] = useState('')
312
+ const [activeTab, setActiveTab] = useState<TabKey>('prompts')
313
+ const [editingPromptId, setEditingPromptId] = useState<string | undefined>()
314
+ const [editingSkillName, setEditingSkillName] = useState<string | undefined>()
315
+
316
+ useEffect(() => {
317
+ void load()
318
+ }, [load])
319
+
320
+ const refresh = (): void => {
321
+ void load()
322
+ }
323
+
324
+ const setDefault = (id: string): void => {
325
+ void setDefaultPreset(id)
326
+ }
327
+
328
+ /** Read a local skill .md file into the import form. */
329
+ const onSkillFile = (file: File | undefined): void => {
330
+ if (file === undefined) return
331
+ setPickedFile(file.name)
332
+ void file.text().then((text) => {
333
+ const first = text.split('\n')[0]?.trim() ?? ''
334
+ const nameMatch = /^#\s+([a-z0-9][a-z0-9-]*)$/i.exec(first)
335
+ if (nameMatch !== null) setSkillName(nameMatch[1]?.toLowerCase() ?? '')
336
+ setSkillContent(text.trim())
337
+ const descLine = text.split('\n').find((l) => l.startsWith('> '))
338
+ if (descLine !== undefined) setSkillDesc(descLine.slice(2).trim())
339
+ }).catch((error: unknown) => console.error('[switchblade] read skill file failed', error))
340
+ }
341
+
342
+ const submitPrompt = (): void => {
343
+ if (promptName.trim() === '' || promptContent.trim() === '') return
344
+ setBusy(true)
345
+ const action = editingPromptId !== undefined
346
+ ? updatePrompt(editingPromptId, { name: promptName, description: promptDesc, content: promptContent })
347
+ : addPrompt({ name: promptName, description: promptDesc, content: promptContent })
348
+ void action
349
+ .catch((error: unknown) => console.error('[switchblade] prompt save failed', error))
350
+ .finally(() => {
351
+ setBusy(false)
352
+ setPromptName(''); setPromptDesc(''); setPromptContent('')
353
+ setEditingPromptId(undefined)
354
+ })
355
+ }
356
+
357
+ const startEditPrompt = (row: { promptId: string; name: string; desc: string; content?: string }): void => {
358
+ setEditingPromptId(row.promptId)
359
+ setPromptName(row.name)
360
+ setPromptDesc(row.desc)
361
+ setPromptContent(row.content ?? '')
362
+ }
363
+
364
+ const togglePrompt = (id: string, enabled: boolean): void => {
365
+ void setPromptEnabled(id, enabled).catch((error: unknown) => console.error('[switchblade] toggle failed', error))
366
+ }
367
+
368
+ const markDefault = (id: string): void => {
369
+ void setDefaultPrompt(id).catch((error: unknown) => console.error('[switchblade] setDefault failed', error))
370
+ }
371
+
372
+ const removePrompt = (id: string): void => {
373
+ void deletePrompt(id).catch((error: unknown) => console.error('[switchblade] delete failed', error))
374
+ }
375
+
376
+ const submitSkill = (): void => {
377
+ if (skillName.trim() === '' || skillContent.trim() === '') return
378
+ setBusy(true)
379
+ const action = editingSkillName !== undefined
380
+ ? updateSkill(editingSkillName, { name: skillName, description: skillDesc, content: skillContent })
381
+ : installSkill({ name: skillName, description: skillDesc, content: skillContent })
382
+ void action
383
+ .catch((error: unknown) => console.error('[switchblade] skill save failed', error))
384
+ .finally(() => {
385
+ setBusy(false)
386
+ setSkillName(''); setSkillDesc(''); setSkillContent('')
387
+ setEditingSkillName(undefined)
388
+ })
389
+ }
390
+
391
+ const startEditSkill = (row: { installedName: string; name: string; desc: string }): void => {
392
+ const found = state.installedSkills.find((s) => s.name === row.installedName)
393
+ setEditingSkillName(row.installedName)
394
+ setSkillName(row.name)
395
+ setSkillDesc(row.desc)
396
+ setSkillContent(found?.content ?? '')
397
+ }
398
+
399
+ const toggleSkill = (name: string, enabled: boolean): void => {
400
+ void setSkillEnabled(name, enabled).catch((error: unknown) => console.error('[switchblade] toggle skill failed', error))
401
+ }
402
+
403
+ const removeSkill = (name: string): void => {
404
+ void uninstallSkill(name).catch((error: unknown) => console.error('[switchblade] uninstall failed', error))
405
+ }
406
+
407
+ /** Adopt a scanned (local) skill into the managed list. */
408
+ const adoptSkill = (name: string): void => {
409
+ const found = state.skills.find((s) => s.name === name)
410
+ if (found === undefined) return
411
+ void installSkill({ name: found.name, description: found.description, content: `# ${found.name}\n\n${found.description}` })
412
+ .catch((error: unknown) => console.error('[switchblade] adopt skill failed', error))
413
+ }
414
+
415
+ const promptRows = state.prompts.map((p) => ({
416
+ id: p.id, name: p.name, desc: p.description, state: p.enabled ? ('enabled' as const) : ('disabled' as const),
417
+ promptId: p.id, isDefault: p.isDefault, content: p.content, promptEnabled: p.enabled,
418
+ }))
419
+ const presetRows = state.presets.map((p) => ({
420
+ id: p.id, name: p.name ?? p.id, desc: p.description ?? p.trust,
421
+ state: p.isDefault ? ('enabled' as const) : ('installed' as const),
422
+ presetId: p.id, isDefault: p.isDefault,
423
+ }))
424
+
425
+ // Merge managed + scanned skills into ONE list, managed first.
426
+ const managedNames = new Set(state.installedSkills.map((s) => s.name))
427
+ const managedRows = state.installedSkills.map((s) => ({
428
+ key: `m-${s.name}`, name: s.name, desc: s.description,
429
+ state: s.enabled ? ('enabled' as const) : ('disabled' as const),
430
+ installedName: s.name, skillEnabled: s.enabled, source: 'managed' as const,
431
+ }))
432
+ const scannedRows = state.skills
433
+ .filter((s) => !managedNames.has(s.name))
434
+ .map((s) => ({
435
+ key: `s-${s.name}`, name: s.name, desc: s.description,
436
+ state: ('installed' as const), installedName: s.name, skillEnabled: false, source: 'scanned' as const,
437
+ }))
438
+ const allSkillRows = [...managedRows, ...scannedRows]
439
+
440
+ const match = (row: { name: string; desc: string }, q: string): boolean => {
441
+ const query = q.trim().toLowerCase()
442
+ if (query === '') return true
443
+ return row.name.toLowerCase().includes(query) || row.desc.toLowerCase().includes(query)
444
+ }
445
+ const filteredPrompts = promptRows.filter((r) => match(r, promptQuery))
446
+ const filteredPresets = presetRows.filter((r) => match(r, presetQuery))
447
+ const filteredSkills = allSkillRows.filter((r) => match(r, skillQuery))
448
+
449
+ const badge = (state: 'enabled' | 'disabled' | 'installed'): CSSProperties => (
450
+ state === 'enabled' ? CSS.badgeEnabled! : state === 'disabled' ? CSS.badgeDisabled! : CSS.badgeInstalled!
451
+ )
452
+ const label = (state: 'enabled' | 'disabled' | 'installed'): string => (
453
+ state === 'enabled' ? t('enabled') : state === 'disabled' ? t('disabled') : t('installed')
454
+ )
455
+
456
+ return (
457
+ <div style={CSS.root}>
458
+ <div style={CSS.head}>
459
+ <div style={CSS.title}>
460
+ <BookIcon size={16} /> <span style={CSS.titleAccent}>Prompt</span>-SkillArmory
461
+ <span style={CSS.versionBadge}>v{ARMORY_VERSION}</span>
462
+ </div>
463
+ <button style={CSS.refreshBtn} onClick={refresh}>{t('refresh')}</button>
464
+ </div>
465
+
466
+ {state.status === 'error' && <div style={CSS.error}>✖ {t('loadFailed')}: {state.message}</div>}
467
+
468
+ {/* Tab bar */}
469
+ <div style={CSS.tabs}>
470
+ <button style={{ ...CSS.tab, ...(activeTab === 'prompts' ? CSS.tabActive : {}) }} onClick={() => setActiveTab('prompts')}>
471
+ {t('promptsTitle')} ({state.status === 'loading' ? '…' : promptRows.length})
472
+ </button>
473
+ <button style={{ ...CSS.tab, ...(activeTab === 'skills' ? CSS.tabActive : {}) }} onClick={() => setActiveTab('skills')}>
474
+ {t('installSkill')} ({state.status === 'loading' ? '…' : allSkillRows.length})
475
+ </button>
476
+ <button style={{ ...CSS.tab, ...(activeTab === 'presets' ? CSS.tabActive : {}) }} onClick={() => setActiveTab('presets')}>
477
+ {t('agentPresetsTitle')} ({state.status === 'loading' ? '…' : presetRows.length})
478
+ </button>
479
+ </div>
480
+
481
+ {/* ── Tab: prompts ─────────────────────────────────────── */}
482
+ {activeTab === 'prompts' && (
483
+ <div style={CSS.columns}>
484
+ <div style={CSS.column}>
485
+ <div style={CSS.form}>
486
+ <input style={CSS.input} placeholder={t('promptNamePlaceholder')} value={promptName} onChange={(e) => setPromptName(e.target.value)} />
487
+ <input style={CSS.input} placeholder={t('promptDescPlaceholder')} value={promptDesc} onChange={(e) => setPromptDesc(e.target.value)} />
488
+ <textarea style={CSS.textarea} placeholder={t('promptContentPlaceholder')} value={promptContent} onChange={(e) => setPromptContent(e.target.value)} />
489
+ <div style={CSS.actions}>
490
+ <button style={CSS.actionBtn} disabled={busy} onClick={submitPrompt}>
491
+ {editingPromptId !== undefined ? t('save') : t('addPrompt')}
492
+ </button>
493
+ {editingPromptId !== undefined && (
494
+ <button style={CSS.actionBtn} onClick={() => { setEditingPromptId(undefined); setPromptName(''); setPromptDesc(''); setPromptContent('') }}>{t('cancel')}</button>
495
+ )}
496
+ </div>
497
+ </div>
498
+ </div>
499
+ <div style={CSS.column}>
500
+ <input style={CSS.searchInput} placeholder={t('searchPlaceholder')} value={promptQuery} onChange={(e) => setPromptQuery(e.target.value)} />
501
+ <div style={CSS.scrollBox}>
502
+ {filteredPrompts.length === 0
503
+ ? <div style={CSS.empty}>{t('empty')}</div>
504
+ : filteredPrompts.map((row) => (
505
+ <div key={row.id} style={CSS.card}>
506
+ <div style={CSS.cardTop}>
507
+ <div style={CSS.name}>{row.isDefault ? '★ ' : ''}{row.name}</div>
508
+ <span style={{ ...CSS.badge, ...row.state === 'enabled' ? CSS.badgeEnabled : CSS.badgeDisabled }}>
509
+ {row.state === 'enabled' ? t('enabled') : t('disabled')}
510
+ </span>
511
+ </div>
512
+ <div style={CSS.desc}>{row.desc || row.content?.slice(0, 80)}</div>
513
+ <div style={CSS.actions}>
514
+ {!row.isDefault && <button style={CSS.actionBtn} onClick={() => markDefault(row.promptId!)}>{t('setDefault')}</button>}
515
+ <button style={CSS.actionBtn} onClick={() => togglePrompt(row.promptId!, !row.promptEnabled)}>
516
+ {row.state === 'enabled' ? t('disable') : t('enable')}
517
+ </button>
518
+ <button style={CSS.actionBtn} onClick={() => startEditPrompt(row)}>{t('edit')}</button>
519
+ <button style={{ ...CSS.actionBtn, ...CSS.dangerBtn }} onClick={() => removePrompt(row.promptId!)}>{t('delete')}</button>
520
+ </div>
521
+ </div>
522
+ ))}
523
+ </div>
524
+ </div>
525
+ </div>
526
+ )}
527
+
528
+ {/* ── Tab: skills (merged managed + scanned) ───────────── */}
529
+ {activeTab === 'skills' && (
530
+ <div style={CSS.columns}>
531
+ <div style={CSS.column}>
532
+ <div style={CSS.form}>
533
+ <label style={CSS.fileBtn}>
534
+ {pickedFile !== '' ? `📄 ${pickedFile}` : t('pickSkillFile')}
535
+ <input type="file" accept=".md,.markdown,text/markdown,text/plain" style={{ display: 'none' }} onChange={(e) => onSkillFile(e.target.files?.[0])} />
536
+ </label>
537
+ <input style={CSS.input} placeholder={t('skillNamePlaceholder')} value={skillName} onChange={(e) => setSkillName(e.target.value)} />
538
+ <input style={CSS.input} placeholder={t('skillDescPlaceholder')} value={skillDesc} onChange={(e) => setSkillDesc(e.target.value)} />
539
+ <textarea style={CSS.textarea} placeholder={t('skillContentPlaceholder')} value={skillContent} onChange={(e) => setSkillContent(e.target.value)} />
540
+ <div style={CSS.actions}>
541
+ <button style={CSS.actionBtn} disabled={busy} onClick={submitSkill}>
542
+ {editingSkillName !== undefined ? t('save') : t('addSkill')}
543
+ </button>
544
+ {editingSkillName !== undefined && (
545
+ <button style={CSS.actionBtn} onClick={() => { setEditingSkillName(undefined); setSkillName(''); setSkillDesc(''); setSkillContent('') }}>{t('cancel')}</button>
546
+ )}
547
+ </div>
548
+ </div>
549
+ {/* CLI entry */}
550
+ <div style={CSS.cliBox}>
551
+ <div style={{ marginBottom: '4px' }}>{t('cliHint')}</div>
552
+ <code style={{ fontSize: '10px', color: PHOSPHOR }}>/armory-skill-dir &lt;目录&gt;</code><br />
553
+ <code style={{ fontSize: '10px', color: PHOSPHOR }}>/armory-install-zip &lt;zip路径&gt;</code>
554
+ </div>
555
+ </div>
556
+ <div style={CSS.column}>
557
+ <input style={CSS.searchInput} placeholder={t('searchPlaceholder')} value={skillQuery} onChange={(e) => setSkillQuery(e.target.value)} />
558
+ <div style={CSS.scrollBox}>
559
+ {filteredSkills.length === 0
560
+ ? <div style={CSS.empty}>{t('empty')}</div>
561
+ : filteredSkills.map((row) => (
562
+ <div key={row.key} style={CSS.card}>
563
+ <div style={CSS.cardTop}>
564
+ <div style={CSS.name}>{row.name}</div>
565
+ <span style={{ ...CSS.badge, ...badge(row.state) }}>{label(row.state)}</span>
566
+ </div>
567
+ <div style={CSS.desc}>{row.desc}</div>
568
+ <div style={CSS.invokeHint}>/ {row.name}</div>
569
+ <div style={CSS.actions}>
570
+ {row.source === 'scanned' ? (
571
+ <button style={CSS.actionBtn} onClick={() => adoptSkill(row.name)}>{t('manage')}</button>
572
+ ) : (
573
+ <>
574
+ <button style={CSS.actionBtn} onClick={() => toggleSkill(row.installedName!, !row.skillEnabled)}>
575
+ {row.state === 'enabled' ? t('disable') : t('enable')}
576
+ </button>
577
+ <button style={CSS.actionBtn} onClick={() => startEditSkill(row)}>{t('edit')}</button>
578
+ <button style={{ ...CSS.actionBtn, ...CSS.dangerBtn }} onClick={() => removeSkill(row.installedName!)}>{t('uninstall')}</button>
579
+ </>
580
+ )}
581
+ </div>
582
+ </div>
583
+ ))}
584
+ </div>
585
+ </div>
586
+ </div>
587
+ )}
588
+
589
+ {/* ── Tab: presets ──────────────────────────────────────── */}
590
+ {activeTab === 'presets' && (
591
+ <div style={CSS.columns}>
592
+ <div style={CSS.column}>
593
+ <div style={CSS.colHeader}>{t('agentPresetsTitle')} ({presetRows.length})</div>
594
+ <input style={CSS.searchInput} placeholder={t('searchPlaceholder')} value={presetQuery} onChange={(e) => setPresetQuery(e.target.value)} />
595
+ <div style={CSS.scrollBox}>
596
+ {filteredPresets.length === 0
597
+ ? <div style={CSS.empty}>{t('empty')}</div>
598
+ : filteredPresets.map((row) => (
599
+ <div key={row.id} style={CSS.card}>
600
+ <div style={CSS.cardTop}>
601
+ <div style={CSS.name}>{row.isDefault ? '★ ' : ''}{row.name}</div>
602
+ <span style={{ ...CSS.badge, ...badge(row.state) }}>{label(row.state)}</span>
603
+ </div>
604
+ <div style={CSS.desc}>{row.desc}</div>
605
+ {!row.isDefault && <button style={CSS.actionBtn} onClick={() => setDefault(row.presetId!)}>{t('setDefault')}</button>}
606
+ </div>
607
+ ))}
608
+ </div>
609
+ </div>
610
+ </div>
611
+ )}
612
+ </div>
613
+ )
614
+ }
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Switchblade management page, browser half: registers the `settings.section`
3
+ * navigation entry and renders the edgelord panel from the connection RPC
4
+ * state. Global scope (root) — one management seat for every session.
5
+ * @module @deepseek-ai/dsh-client-ui-switchblade
6
+ */
7
+
8
+ import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client'
9
+ import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client'
10
+ // Type-only: pulls the locale plugin's Context merge (ctx.locale).
11
+ import type {} from '@deepseek-ai/dsh-client-locale/client'
12
+ // Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
13
+ import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
14
+ // Type-only: pulls the LocaleNamespaceMap merge slot (the 'settings.switchblade' entry).
15
+ import type {} from '@deepseek-ai/dsh-client-ui-slots'
16
+ import { en, NS, zh, type SwitchbladeKey } from './locales.ts'
17
+ import { SwitchbladeSection } from './SwitchbladeSection.tsx'
18
+ import type { SwitchbladeSectionInjected } from './SwitchbladeSection.tsx'
19
+ import { SwitchbladeSectionController } from './store.ts'
20
+
21
+ export { SwitchbladeSection } from './SwitchbladeSection.tsx'
22
+ export type { SwitchbladeSectionInjected, SwitchbladeSectionProps } from './SwitchbladeSection.tsx'
23
+ export { SwitchbladeSectionController } from './store.ts'
24
+ export type { SwitchbladeSectionState, SkillRow, PresetRow } from './store.ts'
25
+
26
+ declare module '@deepseek-ai/dsh-client-ui-slots' {
27
+ interface LocaleNamespaceMap {
28
+ /** Switchblade management page copy. */
29
+ 'settings.switchblade': SwitchbladeKey
30
+ }
31
+ }
32
+
33
+ /** Required services (cordis fiber inject). */
34
+ export const inject = ['slots', 'locale', 'connection', 'sessions']
35
+
36
+ /**
37
+ * Mount the Switchblade settings section.
38
+ * @param ctx - the browser plugin context.
39
+ */
40
+ export function apply(ctx: ClientContext): void {
41
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-switchblade: dictionaries')
42
+
43
+ const api = (ctx.get('connection') as ConnectionHandle).api
44
+ const sessions = ctx.get('sessions') as ISessions
45
+ const controller = new SwitchbladeSectionController(api, () => {
46
+ const state = sessions.list.getSnapshot()
47
+ return state.current === undefined ? undefined : state.current
48
+ })
49
+
50
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
51
+ name: 'settings.section',
52
+ id: 'switchblade',
53
+ order: 30,
54
+ label: () => ctx.locale.bind(NS)('nav'),
55
+ locale: NS,
56
+ inject: (): SwitchbladeSectionInjected => ({
57
+ hooks: { switchblade: controller.store },
58
+ load: () => controller.load(),
59
+ setDefaultPreset: (id: string) => controller.setDefaultPreset(id),
60
+ addPrompt: (input) => controller.addPrompt(input),
61
+ updatePrompt: (id, patch) => controller.updatePrompt(id, patch),
62
+ setPromptEnabled: (id, enabled) => controller.setPromptEnabled(id, enabled),
63
+ setDefaultPrompt: (id) => controller.setDefaultPrompt(id),
64
+ deletePrompt: (id) => controller.deletePrompt(id),
65
+ installSkill: (input) => controller.installSkill(input),
66
+ updateSkill: (name, patch) => controller.updateSkill(name, patch),
67
+ setSkillEnabled: (name, enabled) => controller.setSkillEnabled(name, enabled),
68
+ uninstallSkill: (name) => controller.uninstallSkill(name),
69
+ installSkillFromZip: (name, dataBase64) => controller.installSkillFromZip(name, dataBase64),
70
+ }),
71
+ }, SwitchbladeSection))
72
+ }
73
+
74
+ /** Cordis plugin identity. */
75
+ export const name = 'ui-switchblade'