@skillsgate/tui 0.1.10 → 0.1.13

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.
@@ -1,10 +1,21 @@
1
1
  import { useMemo, useState, useEffect } from "react"
2
+ import fsPromises from "node:fs/promises"
3
+ import os from "node:os"
4
+ import path from "node:path"
2
5
  import fs from "node:fs"
3
- import { useStore } from "../store/context.js"
6
+ import { useKeyboard } from "@opentui/react"
7
+ import { useStore, useDispatch } from "../store/context.js"
8
+ import { useDb } from "../db/context.js"
4
9
  import { AgentFilter } from "../components/agent-filter.js"
5
10
  import { SkillList } from "../components/skill-list.js"
6
11
  import { colors, agentBadges as badgeMap } from "../utils/colors.js"
7
12
  import type { EnrichedSkill } from "../store/types.js"
13
+ import { agents } from "../../../cli/src/core/agents.js"
14
+ import { addSkillToLock } from "../../../cli/src/core/skill-lock.js"
15
+ import { sanitizeName, installSkillForAgent } from "../../../cli/src/core/installer.js"
16
+
17
+ const home = os.homedir()
18
+ const CANONICAL_SKILLS_DIR = path.join(home, ".agents", "skills")
8
19
 
9
20
  /**
10
21
  * Reads the full SKILL.md content for inline display.
@@ -44,11 +55,24 @@ function stripFrontmatter(content: string): string {
44
55
  */
45
56
  export function HomeView() {
46
57
  const state = useStore()
58
+ const dispatch = useDispatch()
59
+ const { settings } = useDb()
60
+ const [collectionsVersion, setCollectionsVersion] = useState(0)
61
+ const [showCollections, setShowCollections] = useState(false)
62
+ const [showCreateSkill, setShowCreateSkill] = useState(false)
47
63
 
48
64
  // Apply agent filter and text filter
65
+ const collections = settings.get<Record<string, string[]>>("collections.skills", {})
66
+ const selectedCollection = settings.get<string | null>("ui.home.selectedCollection", null)
67
+
49
68
  const filteredSkills = useMemo(() => {
50
69
  let skills = state.installedSkills
51
70
 
71
+ if (selectedCollection) {
72
+ const ids = new Set(collections[selectedCollection] || [])
73
+ skills = skills.filter((skill) => ids.has(skill.canonicalPath))
74
+ }
75
+
52
76
  // Agent filter
53
77
  if (state.selectedAgentFilter !== "all") {
54
78
  skills = skills.filter((s) =>
@@ -62,12 +86,98 @@ export function HomeView() {
62
86
  skills = skills.filter(
63
87
  (s) =>
64
88
  s.name.toLowerCase().includes(q) ||
65
- s.description.toLowerCase().includes(q)
89
+ s.description.toLowerCase().includes(q) ||
90
+ s.filePath.toLowerCase().includes(q) ||
91
+ s.canonicalPath.toLowerCase().includes(q) ||
92
+ (s.projectName?.toLowerCase().includes(q) ?? false) ||
93
+ (s.lock?.originalUrl?.toLowerCase().includes(q) ?? false) ||
94
+ s.supportingFiles.some((file) => file.relativePath.toLowerCase().includes(q))
66
95
  )
67
96
  }
68
97
 
69
98
  return skills
70
- }, [state.installedSkills, state.selectedAgentFilter, state.installedFilter])
99
+ }, [state.installedSkills, state.selectedAgentFilter, state.installedFilter, selectedCollection, collections, collectionsVersion])
100
+
101
+ useKeyboard((key) => {
102
+ if (state.activeView !== "home") return
103
+ if (state.showHelp) return
104
+ if (state.focusedPane === "search") return
105
+ if (showCollections || showCreateSkill) return
106
+
107
+ if (key.name === "c") {
108
+ setShowCollections(true)
109
+ return
110
+ }
111
+
112
+ if (key.name === "n") {
113
+ setShowCreateSkill(true)
114
+ return
115
+ }
116
+ })
117
+
118
+ async function createLocalSkill(data: {
119
+ name: string
120
+ description: string
121
+ content: string
122
+ targets: string[]
123
+ }) {
124
+ const name = data.name.trim()
125
+ if (!name) return
126
+ const description = data.description.trim() || name
127
+ const safeName = sanitizeName(name)
128
+ const canonicalDir = path.join(CANONICAL_SKILLS_DIR, safeName)
129
+ const filePath = path.join(canonicalDir, "SKILL.md")
130
+
131
+ await fsPromises.mkdir(canonicalDir, { recursive: true })
132
+ const body = (data.content.trim() || `---
133
+ name: ${safeName}
134
+ description: ${description}
135
+ ---
136
+
137
+ # ${name}
138
+
139
+ ## Instructions
140
+
141
+ Add your skill instructions here.
142
+ `).trimEnd() + "\n"
143
+ await fsPromises.writeFile(filePath, body, "utf-8")
144
+
145
+ const selectedTargets =
146
+ data.targets.length > 0
147
+ ? data.targets
148
+ : settings.get<string[]>("install.defaultAgents", [])
149
+ const mirrorAgents = settings.get<string[]>("sync.mirrorAgents", [])
150
+ const targetNames = Array.from(new Set([...selectedTargets, ...mirrorAgents]))
151
+ for (const targetName of targetNames) {
152
+ const agent = agents[targetName]
153
+ if (agent) {
154
+ await installSkillForAgent(
155
+ {
156
+ name,
157
+ path: canonicalDir,
158
+ entryPath: filePath,
159
+ } as any,
160
+ agent,
161
+ "global",
162
+ "symlink",
163
+ )
164
+ }
165
+ }
166
+
167
+ const now = new Date().toISOString()
168
+ await addSkillToLock(safeName, {
169
+ source: canonicalDir,
170
+ sourceType: "local",
171
+ originalUrl: canonicalDir,
172
+ skillFolderHash: "",
173
+ })
174
+
175
+ dispatch({ type: "REFRESH_SKILLS" })
176
+ dispatch({
177
+ type: "SHOW_NOTIFICATION",
178
+ notification: { type: "success", message: `Created "${name}"` },
179
+ })
180
+ }
71
181
 
72
182
  return (
73
183
  <box style={{ flexDirection: "row", width: "100%", flexGrow: 1 }}>
@@ -78,10 +188,10 @@ export function HomeView() {
78
188
  <box
79
189
  style={{
80
190
  width: "30%",
81
- borderRight: true,
191
+ border: true,
82
192
  borderColor: state.focusedPane === "list" ? colors.primary : colors.border,
83
193
  flexDirection: "column",
84
- }}
194
+ } as any}
85
195
  >
86
196
  <SkillList skills={filteredSkills} />
87
197
  </box>
@@ -89,7 +199,11 @@ export function HomeView() {
89
199
  {/* RIGHT: Detail panel */}
90
200
  <box style={{ flexGrow: 1, flexDirection: "column" }}>
91
201
  {state.selectedSkill ? (
92
- <DetailPanel skill={state.selectedSkill} />
202
+ <DetailPanel
203
+ skill={state.selectedSkill}
204
+ collections={collections}
205
+ selectedCollection={selectedCollection}
206
+ />
93
207
  ) : (
94
208
  <box style={{ padding: 1 }}>
95
209
  <text fg={colors.textDim}>
@@ -100,6 +214,35 @@ export function HomeView() {
100
214
  </box>
101
215
  )}
102
216
  </box>
217
+
218
+ {showCollections ? (
219
+ <CollectionOverlay
220
+ collections={collections}
221
+ selectedCollection={selectedCollection}
222
+ selectedSkill={state.selectedSkill}
223
+ onClose={() => setShowCollections(false)}
224
+ onApplyFilter={(name) => {
225
+ settings.set("ui.home.selectedCollection", name)
226
+ setCollectionsVersion((value) => value + 1)
227
+ }}
228
+ onSaveCollections={(next) => {
229
+ settings.set("collections.skills", next)
230
+ setCollectionsVersion((value) => value + 1)
231
+ }}
232
+ />
233
+ ) : null}
234
+
235
+ {showCreateSkill ? (
236
+ <CreateSkillOverlay
237
+ agents={state.detectedAgents}
238
+ defaultTargets={settings.get<string[]>("install.defaultAgents", [])}
239
+ onClose={() => setShowCreateSkill(false)}
240
+ onCreate={async (data) => {
241
+ await createLocalSkill(data)
242
+ setShowCreateSkill(false)
243
+ }}
244
+ />
245
+ ) : null}
103
246
  </box>
104
247
  )
105
248
  }
@@ -108,10 +251,11 @@ export function HomeView() {
108
251
 
109
252
  interface DetailPanelProps {
110
253
  skill: EnrichedSkill
254
+ collections: Record<string, string[]>
255
+ selectedCollection: string | null
111
256
  }
112
257
 
113
- function DetailPanel({ skill }: DetailPanelProps) {
114
- const state = useStore()
258
+ function DetailPanel({ skill, collections, selectedCollection }: DetailPanelProps) {
115
259
  const [content, setContent] = useState("")
116
260
 
117
261
  useEffect(() => {
@@ -180,10 +324,66 @@ function DetailPanel({ skill }: DetailPanelProps) {
180
324
  </box>
181
325
  ) : null}
182
326
 
327
+ <box style={{ flexDirection: "row", minHeight: 1 }}>
328
+ <text fg={colors.textDim}>Scope: </text>
329
+ <text fg={colors.secondary}>{skill.scope}</text>
330
+ {skill.projectName ? (
331
+ <>
332
+ <text fg={colors.textDim}> Project: </text>
333
+ <text fg={colors.secondary}>{skill.projectName}</text>
334
+ </>
335
+ ) : null}
336
+ </box>
337
+
338
+ <box style={{ flexDirection: "column" }}>
339
+ <text fg={colors.textDim}>Path: </text>
340
+ <text fg={colors.secondary}>{skill.canonicalPath}</text>
341
+ </box>
342
+
343
+ <box style={{ flexDirection: "column" }}>
344
+ <text fg={colors.textDim}>Collections:</text>
345
+ {Object.keys(collections).length === 0 ? (
346
+ <text fg={colors.textDim}> none</text>
347
+ ) : (
348
+ Object.entries(collections).map(([name, items]) => (
349
+ <text
350
+ key={name}
351
+ fg={
352
+ items.includes(skill.canonicalPath)
353
+ ? colors.primary
354
+ : selectedCollection === name
355
+ ? colors.warning
356
+ : colors.textDim
357
+ }
358
+ >
359
+ {" "}{items.includes(skill.canonicalPath) ? "[x]" : "[ ]"} {name}
360
+ </text>
361
+ ))
362
+ )}
363
+ </box>
364
+
365
+ {skill.supportingFiles.length > 0 ? (
366
+ <box style={{ flexDirection: "column" }}>
367
+ <text fg={colors.textDim}>
368
+ Supporting files ({skill.supportingFiles.length}):
369
+ </text>
370
+ {skill.supportingFiles.slice(0, 10).map((file) => (
371
+ <text key={file.relativePath} fg={colors.secondary}>
372
+ {" "}{file.relativePath}
373
+ </text>
374
+ ))}
375
+ {skill.supportingFiles.length > 10 ? (
376
+ <text fg={colors.textDim}>
377
+ {" "}+{skill.supportingFiles.length - 10} more
378
+ </text>
379
+ ) : null}
380
+ </box>
381
+ ) : null}
382
+
183
383
  <text>{" "}</text>
184
384
 
185
385
  {/* Shortcut hints */}
186
- <text fg={colors.textDim}>v=view detail d=remove u=update Tab=switch pane</text>
386
+ <text fg={colors.textDim}>v=view detail d=remove u=update n=create skill c=collections Tab=switch pane</text>
187
387
  <text fg={colors.border}>---</text>
188
388
 
189
389
  {/* SKILL.md content */}
@@ -216,3 +416,325 @@ function DetailPanel({ skill }: DetailPanelProps) {
216
416
  </scrollbox>
217
417
  )
218
418
  }
419
+
420
+ function CollectionOverlay({
421
+ collections,
422
+ selectedCollection,
423
+ selectedSkill,
424
+ onClose,
425
+ onApplyFilter,
426
+ onSaveCollections,
427
+ }: {
428
+ collections: Record<string, string[]>
429
+ selectedCollection: string | null
430
+ selectedSkill: EnrichedSkill | null
431
+ onClose: () => void
432
+ onApplyFilter: (name: string | null) => void
433
+ onSaveCollections: (next: Record<string, string[]>) => void
434
+ }) {
435
+ const dispatch = useDispatch()
436
+ const entries = [{ name: "(all)", count: 0 }, ...Object.keys(collections).sort().map((name) => ({
437
+ name,
438
+ count: collections[name]?.length ?? 0,
439
+ }))]
440
+ const [selectedIndex, setSelectedIndex] = useState(0)
441
+ const [inputMode, setInputMode] = useState<"create" | "rename" | null>(null)
442
+ const [draftName, setDraftName] = useState("")
443
+
444
+ useKeyboard((key) => {
445
+ if (inputMode) return
446
+ if (key.name === "escape") {
447
+ onClose()
448
+ return
449
+ }
450
+ if (key.name === "up" || key.name === "k") {
451
+ setSelectedIndex((value) => Math.max(0, value - 1))
452
+ return
453
+ }
454
+ if (key.name === "down" || key.name === "j") {
455
+ setSelectedIndex((value) => Math.min(entries.length - 1, value + 1))
456
+ return
457
+ }
458
+ if (key.name === "return") {
459
+ const name = entries[selectedIndex]?.name
460
+ onApplyFilter(name === "(all)" ? null : name)
461
+ onClose()
462
+ return
463
+ }
464
+ if (key.name === "a") {
465
+ setDraftName("")
466
+ setInputMode("create")
467
+ return
468
+ }
469
+ if (key.name === "r" && selectedIndex > 0) {
470
+ setDraftName(entries[selectedIndex].name)
471
+ setInputMode("rename")
472
+ return
473
+ }
474
+ if (key.name === "d" && selectedIndex > 0) {
475
+ const next = { ...collections }
476
+ delete next[entries[selectedIndex].name]
477
+ onSaveCollections(next)
478
+ setSelectedIndex((value) => Math.max(0, Math.min(value - 1, entries.length - 2)))
479
+ return
480
+ }
481
+ if ((key.name === "space" || key.name === "f") && selectedIndex > 0 && selectedSkill) {
482
+ const name = entries[selectedIndex].name
483
+ const next = { ...collections }
484
+ const current = new Set(next[name] || [])
485
+ if (current.has(selectedSkill.canonicalPath)) current.delete(selectedSkill.canonicalPath)
486
+ else current.add(selectedSkill.canonicalPath)
487
+ next[name] = Array.from(current).sort()
488
+ onSaveCollections(next)
489
+ dispatch({
490
+ type: "SHOW_NOTIFICATION",
491
+ notification: { type: "info", message: `Updated ${name}` },
492
+ })
493
+ }
494
+ })
495
+
496
+ return (
497
+ <box
498
+ style={{
499
+ position: "absolute",
500
+ width: "100%",
501
+ height: "100%",
502
+ justifyContent: "center",
503
+ alignItems: "center",
504
+ backgroundColor: colors.bg,
505
+ }}
506
+ >
507
+ <box
508
+ style={{
509
+ width: 64,
510
+ border: true,
511
+ borderColor: colors.primary,
512
+ backgroundColor: "#1a1a2e",
513
+ flexDirection: "column",
514
+ paddingLeft: 1,
515
+ paddingRight: 1,
516
+ paddingTop: 1,
517
+ paddingBottom: 1,
518
+ }}
519
+ title="Collections"
520
+ >
521
+ {inputMode ? (
522
+ <>
523
+ <text fg={colors.text}>
524
+ {inputMode === "create" ? "New collection name" : "Rename collection"}
525
+ </text>
526
+ <box
527
+ style={{
528
+ height: 3,
529
+ width: "100%",
530
+ border: true,
531
+ borderColor: colors.primary,
532
+ paddingLeft: 1,
533
+ paddingRight: 1,
534
+ }}
535
+ >
536
+ <input
537
+ placeholder="collection name"
538
+ focused={true}
539
+ onInput={(value: string) => setDraftName(value)}
540
+ onSubmit={((value: string) => {
541
+ const trimmed = value.trim()
542
+ if (!trimmed) {
543
+ setInputMode(null)
544
+ return
545
+ }
546
+ const next = { ...collections }
547
+ if (inputMode === "create") {
548
+ next[trimmed] = next[trimmed] || []
549
+ } else {
550
+ const currentName = entries[selectedIndex].name
551
+ next[trimmed] = next[currentName] || []
552
+ delete next[currentName]
553
+ }
554
+ onSaveCollections(next)
555
+ setInputMode(null)
556
+ }) as any}
557
+ />
558
+ </box>
559
+ <text fg={colors.textDim}>Enter=save Esc close overlay</text>
560
+ </>
561
+ ) : (
562
+ <>
563
+ {entries.map((entry, index) => (
564
+ <box
565
+ key={entry.name}
566
+ style={{
567
+ width: "100%",
568
+ flexDirection: "row",
569
+ backgroundColor: index === selectedIndex ? colors.bgAlt : "transparent",
570
+ }}
571
+ >
572
+ <text
573
+ fg={
574
+ entry.name === "(all)"
575
+ ? selectedCollection === null && index === selectedIndex
576
+ ? colors.primary
577
+ : colors.text
578
+ : selectedCollection === entry.name
579
+ ? colors.warning
580
+ : colors.text
581
+ }
582
+ style={{ flexGrow: 1 }}
583
+ >
584
+ {entry.name}
585
+ </text>
586
+ {entry.name !== "(all)" ? (
587
+ <text fg={colors.textDim}>{entry.count}</text>
588
+ ) : null}
589
+ </box>
590
+ ))}
591
+ <text>{" "}</text>
592
+ <text fg={colors.textDim}>
593
+ Enter=filter a=new r=rename d=delete f=toggle selected skill Esc=close
594
+ </text>
595
+ </>
596
+ )}
597
+ </box>
598
+ </box>
599
+ )
600
+ }
601
+
602
+ function CreateSkillOverlay({
603
+ agents,
604
+ defaultTargets,
605
+ onClose,
606
+ onCreate,
607
+ }: {
608
+ agents: Array<{ name: string; displayName: string }>
609
+ defaultTargets: string[]
610
+ onClose: () => void
611
+ onCreate: (data: { name: string; description: string; content: string; targets: string[] }) => Promise<void>
612
+ }) {
613
+ const [name, setName] = useState("")
614
+ const [description, setDescription] = useState("")
615
+ const [content, setContent] = useState("")
616
+ const [targets, setTargets] = useState<string[]>(defaultTargets)
617
+ const [focusedField, setFocusedField] = useState<0 | 1 | 2>(0)
618
+ const [saving, setSaving] = useState(false)
619
+
620
+ useKeyboard((key) => {
621
+ if (key.name === "escape" && !saving) {
622
+ onClose()
623
+ return
624
+ }
625
+ if (key.name === "tab" && !saving) {
626
+ setFocusedField((value) => (value === 0 ? 1 : value === 1 ? 2 : 0))
627
+ return
628
+ }
629
+ if (/^[1-9]$/.test(key.raw ?? "") && !saving) {
630
+ const idx = Number(key.raw) - 1
631
+ const target = agents[idx]
632
+ if (target) {
633
+ setTargets((prev) =>
634
+ prev.includes(target.name)
635
+ ? prev.filter((value) => value !== target.name)
636
+ : [...prev, target.name],
637
+ )
638
+ }
639
+ return
640
+ }
641
+ if (key.name === "s" && key.ctrl && !saving && name.trim()) {
642
+ setSaving(true)
643
+ onCreate({ name: name.trim(), description: description.trim(), content, targets }).finally(() => {
644
+ setSaving(false)
645
+ })
646
+ }
647
+ })
648
+
649
+ return (
650
+ <box
651
+ style={{
652
+ position: "absolute",
653
+ width: "100%",
654
+ height: "100%",
655
+ justifyContent: "center",
656
+ alignItems: "center",
657
+ backgroundColor: colors.bg,
658
+ }}
659
+ >
660
+ <box
661
+ style={{
662
+ width: 72,
663
+ border: true,
664
+ borderColor: colors.primary,
665
+ backgroundColor: "#1a1a2e",
666
+ flexDirection: "column",
667
+ paddingLeft: 1,
668
+ paddingRight: 1,
669
+ paddingTop: 1,
670
+ paddingBottom: 1,
671
+ }}
672
+ title="Create Skill"
673
+ >
674
+ <text fg={colors.text}>Name</text>
675
+ <box
676
+ style={{
677
+ height: 3,
678
+ width: "100%",
679
+ border: true,
680
+ borderColor: focusedField === 0 ? colors.primary : colors.border,
681
+ paddingLeft: 1,
682
+ paddingRight: 1,
683
+ }}
684
+ >
685
+ <input
686
+ placeholder="skill name"
687
+ focused={focusedField === 0 && !saving}
688
+ onInput={(value: string) => setName(value)}
689
+ onSubmit={() => setFocusedField(1)}
690
+ />
691
+ </box>
692
+ <text fg={colors.text}>Description</text>
693
+ <box
694
+ style={{
695
+ height: 3,
696
+ width: "100%",
697
+ border: true,
698
+ borderColor: focusedField === 1 ? colors.primary : colors.border,
699
+ paddingLeft: 1,
700
+ paddingRight: 1,
701
+ }}
702
+ >
703
+ <input
704
+ placeholder="short description"
705
+ focused={focusedField === 1 && !saving}
706
+ onInput={(value: string) => setDescription(value)}
707
+ onSubmit={() => setFocusedField(2)}
708
+ />
709
+ </box>
710
+ <text fg={colors.text}>Content</text>
711
+ <box
712
+ style={{
713
+ height: 3,
714
+ width: "100%",
715
+ border: true,
716
+ borderColor: focusedField === 2 ? colors.primary : colors.border,
717
+ paddingLeft: 1,
718
+ paddingRight: 1,
719
+ }}
720
+ >
721
+ <input
722
+ placeholder="optional SKILL.md body or frontmatter"
723
+ focused={focusedField === 2 && !saving}
724
+ onInput={(value: string) => setContent(value)}
725
+ onSubmit={() => {}}
726
+ />
727
+ </box>
728
+ <text>{" "}</text>
729
+ <text fg={colors.text}>Targets</text>
730
+ {agents.map((agent, index) => (
731
+ <text key={agent.name} fg={targets.includes(agent.name) ? colors.primary : colors.textDim}>
732
+ {index + 1}. {targets.includes(agent.name) ? "[x]" : "[ ]"} {agent.displayName}
733
+ </text>
734
+ ))}
735
+ <text>{" "}</text>
736
+ <text fg={colors.textDim}>Tab=switch field 1-9 toggle targets Ctrl+S=create Esc=cancel</text>
737
+ </box>
738
+ </box>
739
+ )
740
+ }
@@ -174,7 +174,7 @@ export function LoginView() {
174
174
  <input
175
175
  placeholder="XXXX-XXXX"
176
176
  focused={state.activeView === "login" && step === "code" && !state.showHelp}
177
- onSubmit={handleCodeSubmit}
177
+ onSubmit={handleCodeSubmit as any}
178
178
  />
179
179
  </box>
180
180
  <text>{" "}</text>