@skillsgate/tui 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/bin/skillsgate-tui +28 -0
  2. package/bunfig.toml +3 -0
  3. package/package.json +24 -0
  4. package/src/app.tsx +18 -0
  5. package/src/components/agent-filter.tsx +162 -0
  6. package/src/components/confirm-dialog.tsx +56 -0
  7. package/src/components/help-overlay.tsx +101 -0
  8. package/src/components/layout.tsx +272 -0
  9. package/src/components/search-input.tsx +48 -0
  10. package/src/components/skill-list-item.tsx +45 -0
  11. package/src/components/skill-list.tsx +245 -0
  12. package/src/components/status-bar.tsx +34 -0
  13. package/src/data/api-client.ts +151 -0
  14. package/src/data/use-agents.ts +41 -0
  15. package/src/data/use-auth.ts +136 -0
  16. package/src/data/use-favorites.ts +147 -0
  17. package/src/data/use-installed-skills.ts +128 -0
  18. package/src/data/use-search.ts +118 -0
  19. package/src/data/use-skill-actions.ts +333 -0
  20. package/src/db/context.tsx +38 -0
  21. package/src/db/index.ts +19 -0
  22. package/src/db/migrations.ts +72 -0
  23. package/src/db/servers.ts +154 -0
  24. package/src/db/settings.ts +43 -0
  25. package/src/db/skills.ts +138 -0
  26. package/src/db/ssh.ts +319 -0
  27. package/src/index.tsx +37 -0
  28. package/src/store/context.tsx +26 -0
  29. package/src/store/reducers.ts +126 -0
  30. package/src/store/types.ts +124 -0
  31. package/src/utils/colors.ts +42 -0
  32. package/src/views/add-server.tsx +240 -0
  33. package/src/views/discover.tsx +419 -0
  34. package/src/views/favorites.tsx +358 -0
  35. package/src/views/home.tsx +218 -0
  36. package/src/views/login.tsx +202 -0
  37. package/src/views/server-skills.tsx +269 -0
  38. package/src/views/servers.tsx +449 -0
  39. package/src/views/settings.tsx +185 -0
  40. package/src/views/skill-detail.tsx +497 -0
  41. package/tsconfig.json +18 -0
@@ -0,0 +1,419 @@
1
+ import { useState, useEffect } from "react"
2
+ import { useKeyboard } from "@opentui/react"
3
+ import { useStore, useDispatch } from "../store/context.js"
4
+ import { useSearch } from "../data/use-search.js"
5
+ import { useSkillActions } from "../data/use-skill-actions.js"
6
+ import { ConfirmDialog } from "../components/confirm-dialog.js"
7
+ import type { CatalogSkill, SearchMode } from "../data/api-client.js"
8
+ import { colors } from "../utils/colors.js"
9
+
10
+ /**
11
+ * Discover view: two-column layout with search mode toggle.
12
+ * LEFT - Search input + mode toggle + results list (40%)
13
+ * RIGHT - Selected result detail (flexGrow)
14
+ *
15
+ * Search modes:
16
+ * - Keyword (public, no auth): ILIKE pattern matching, unlimited
17
+ * - Semantic (auth required): AI-powered vector search, 30/day limit
18
+ * Toggle with 'm' key.
19
+ */
20
+ export function DiscoverView() {
21
+ const state = useStore()
22
+ const dispatch = useDispatch()
23
+ const [query, setQuery] = useState("")
24
+ const [searchMode, setSearchMode] = useState<SearchMode>("keyword")
25
+ const [selectedIndex, setSelectedIndex] = useState(0)
26
+ const [installTarget, setInstallTarget] = useState<CatalogSkill | null>(null)
27
+ const [previewSkill, setPreviewSkill] = useState<CatalogSkill | null>(null)
28
+
29
+ const token = state.auth?.token ?? null
30
+ const isAuthenticated = !!state.auth
31
+
32
+ // Auto-focus search input when Discover view mounts
33
+ useEffect(() => {
34
+ if (state.activeView === "discover") {
35
+ dispatch({ type: "SET_FOCUSED_PANE", pane: "search" })
36
+ }
37
+ }, [state.activeView])
38
+
39
+ const { results, loading, error, total, hasMore, loadMore, remainingSearches } =
40
+ useSearch(query, searchMode, token)
41
+ const { installSkill } = useSkillActions()
42
+
43
+ // Update preview when selection changes
44
+ useEffect(() => {
45
+ if (results[selectedIndex]) {
46
+ setPreviewSkill(results[selectedIndex])
47
+ } else {
48
+ setPreviewSkill(null)
49
+ }
50
+ }, [selectedIndex, results])
51
+
52
+ // Keyboard navigation for the discover list
53
+ useKeyboard((key) => {
54
+ if (state.activeView !== "discover") return
55
+ if (state.showHelp) return
56
+ if (state.focusedPane === "search") return
57
+ if (installTarget) return
58
+
59
+ // j/k or arrow keys
60
+ if (key.name === "up" || (key.name === "k" && !key.ctrl)) {
61
+ setSelectedIndex((i) => Math.max(0, i - 1))
62
+ }
63
+ if (key.name === "down" || (key.name === "j" && !key.ctrl)) {
64
+ setSelectedIndex((i) => {
65
+ const next = Math.min(results.length - 1, i + 1)
66
+ // If we're near the bottom and there's more, load next page
67
+ if (next >= results.length - 3 && hasMore && !loading) {
68
+ loadMore()
69
+ }
70
+ return next
71
+ })
72
+ }
73
+
74
+ // g = first, G = last
75
+ if (key.name === "g" && !key.shift) {
76
+ setSelectedIndex(0)
77
+ }
78
+ if (key.name === "g" && key.shift) {
79
+ setSelectedIndex(Math.max(0, results.length - 1))
80
+ }
81
+
82
+ // v to open full detail view
83
+ if (key.name === "v" && results[selectedIndex]) {
84
+ const skill = results[selectedIndex]
85
+ dispatch({
86
+ type: "SELECT_SKILL",
87
+ skill: catalogSkillToEnriched(skill),
88
+ })
89
+ return
90
+ }
91
+
92
+ // i to install
93
+ if (key.name === "i" && results[selectedIndex]) {
94
+ setInstallTarget(results[selectedIndex])
95
+ return
96
+ }
97
+
98
+ // m to toggle search mode
99
+ if (key.name === "m") {
100
+ if (searchMode === "keyword") {
101
+ if (!isAuthenticated) {
102
+ dispatch({
103
+ type: "SET_NOTIFICATION",
104
+ notification: { type: "info", message: "Sign in to use AI search (press l)" },
105
+ })
106
+ return
107
+ }
108
+ setSearchMode("semantic")
109
+ } else {
110
+ setSearchMode("keyword")
111
+ }
112
+ setSelectedIndex(0)
113
+ return
114
+ }
115
+ })
116
+
117
+ // Confirm dialog for install
118
+ if (installTarget) {
119
+ return (
120
+ <ConfirmDialog
121
+ message={`Install "${installTarget.name}"?`}
122
+ onConfirm={async () => {
123
+ const skill = catalogSkillToEnriched(installTarget)
124
+ setInstallTarget(null)
125
+ await installSkill(skill)
126
+ }}
127
+ onCancel={() => setInstallTarget(null)}
128
+ />
129
+ )
130
+ }
131
+
132
+ return (
133
+ <box style={{ flexDirection: "column", width: "100%", flexGrow: 1 }}>
134
+ {/* Search input */}
135
+ {/* Search input -- only render <input> when focused to prevent click-to-type desync */}
136
+ <box
137
+ style={{
138
+ height: 3,
139
+ width: "100%",
140
+ border: true,
141
+ borderColor: state.focusedPane === "search" ? colors.primary : colors.border,
142
+ paddingLeft: 1,
143
+ paddingRight: 1,
144
+ }}
145
+ title={state.focusedPane === "search"
146
+ ? (searchMode === "semantic" ? "AI Search" : "Keyword Search")
147
+ : "/ to search"}
148
+ >
149
+ {state.focusedPane === "search" ? (
150
+ <input
151
+ placeholder={
152
+ searchMode === "semantic"
153
+ ? 'AI search -- try "audit website performance" (Enter to search)'
154
+ : "Search by keyword... (Enter to search)"
155
+ }
156
+ focused={state.activeView === "discover" && !state.showHelp}
157
+ onSubmit={(value: string) => {
158
+ setQuery(value)
159
+ setSelectedIndex(0)
160
+ }}
161
+ />
162
+ ) : (
163
+ <text fg={colors.textDim}>/ to search, Tab to cycle panes</text>
164
+ )}
165
+ </box>
166
+
167
+ {/* Status line: mode toggle + results + remaining */}
168
+ <box
169
+ style={{
170
+ height: 1,
171
+ width: "100%",
172
+ paddingLeft: 1,
173
+ backgroundColor: colors.bgAlt,
174
+ flexDirection: "row",
175
+ }}
176
+ >
177
+ {/* Mode toggle: show both options, highlight active */}
178
+ <text fg={searchMode === "keyword" ? colors.primary : colors.textDim}>
179
+ {searchMode === "keyword" ? "[Keyword]" : " Keyword "}
180
+ </text>
181
+ <text fg={colors.textDim}>{" | "}</text>
182
+ <text fg={searchMode === "semantic" ? colors.warning : colors.textDim}>
183
+ {searchMode === "semantic" ? "[AI Search]" : (isAuthenticated ? " AI Search " : " AI Search (login) ")}
184
+ </text>
185
+ <text fg={colors.textDim}>{" m=switch "}</text>
186
+
187
+ {/* Results info */}
188
+ <text fg={colors.textDim}>
189
+ {loading
190
+ ? "Loading..."
191
+ : error
192
+ ? error === "AUTH_EXPIRED"
193
+ ? "Session expired -- press l to re-login"
194
+ : error === "RATE_LIMIT"
195
+ ? "Daily limit reached -- switch to keyword (m)"
196
+ : `Error: ${error}`
197
+ : query.trim()
198
+ ? `${results.length} result${results.length !== 1 ? "s" : ""}`
199
+ : `${results.length}/${total} skills`}
200
+ </text>
201
+
202
+ {/* Remaining searches (semantic only) */}
203
+ {searchMode === "semantic" && remainingSearches !== null ? (
204
+ <text fg={remainingSearches <= 5 ? colors.error : colors.textDim}>
205
+ {` ${remainingSearches} left today`}
206
+ </text>
207
+ ) : null}
208
+ </box>
209
+
210
+ {/* Two-column content: results list | detail */}
211
+ <box style={{ flexDirection: "row", flexGrow: 1, width: "100%" }}>
212
+ {/* LEFT: Results list */}
213
+ <box
214
+ style={{
215
+ width: "40%",
216
+ borderRight: true,
217
+ borderColor: state.focusedPane === "list" ? colors.primary : colors.border,
218
+ flexDirection: "column",
219
+ }}
220
+ >
221
+ {/* List header */}
222
+ <box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
223
+ <text fg={colors.textDim}>RESULTS</text>
224
+ </box>
225
+
226
+ {results.length === 0 && !loading ? (
227
+ <box style={{ padding: 1 }}>
228
+ <text fg={colors.textDim}>
229
+ {query.trim()
230
+ ? "No skills found matching your query."
231
+ : "No skills available in the catalog."}
232
+ </text>
233
+ </box>
234
+ ) : (
235
+ <scrollbox
236
+ focused={state.activeView === "discover" && state.focusedPane === "list" && !state.showHelp}
237
+ style={{
238
+ width: "100%",
239
+ flexGrow: 1,
240
+ rootOptions: { backgroundColor: colors.bg },
241
+ viewportOptions: { backgroundColor: colors.bg },
242
+ contentOptions: { backgroundColor: colors.bg },
243
+ scrollbarOptions: {
244
+ trackOptions: {
245
+ foregroundColor: colors.primary,
246
+ backgroundColor: colors.border,
247
+ },
248
+ },
249
+ }}
250
+ >
251
+ {results.map((skill, i) => (
252
+ <box
253
+ key={skill.id ?? `${skill.slug}-${i}`}
254
+ style={{
255
+ width: "100%",
256
+ paddingLeft: 1,
257
+ paddingRight: 1,
258
+ flexDirection: "row",
259
+ backgroundColor: i === selectedIndex ? colors.bgAlt : "transparent",
260
+ }}
261
+ >
262
+ <text fg={i === selectedIndex ? colors.primary : colors.text}>
263
+ {skill.name}
264
+ </text>
265
+ </box>
266
+ ))}
267
+ {hasMore && (
268
+ <box style={{ paddingLeft: 1, height: 1 }}>
269
+ <text fg={colors.textDim}>
270
+ {loading ? "Loading more..." : "Scroll down to load more..."}
271
+ </text>
272
+ </box>
273
+ )}
274
+ </scrollbox>
275
+ )}
276
+ </box>
277
+
278
+ {/* RIGHT: Detail panel */}
279
+ <box style={{ flexGrow: 1, flexDirection: "column" }}>
280
+ {previewSkill ? (
281
+ <DiscoverDetailPanel skill={previewSkill} />
282
+ ) : (
283
+ <box style={{ padding: 1 }}>
284
+ <text fg={colors.textDim}>Select a skill to view details</text>
285
+ </box>
286
+ )}
287
+ </box>
288
+ </box>
289
+ </box>
290
+ )
291
+ }
292
+
293
+ // ---------- Inline Detail Panel ----------
294
+
295
+ interface DiscoverDetailPanelProps {
296
+ skill: CatalogSkill
297
+ }
298
+
299
+ function DiscoverDetailPanel({ skill }: DiscoverDetailPanelProps) {
300
+ const description = skill.summary || skill.description || ""
301
+ const categories = skill.categories?.join(", ") ?? ""
302
+ const capabilities = skill.capabilities?.join(", ") ?? ""
303
+ const keywords = skill.keywords?.join(", ") ?? ""
304
+
305
+ return (
306
+ <scrollbox
307
+ focused={false}
308
+ style={{
309
+ width: "100%",
310
+ flexGrow: 1,
311
+ rootOptions: { backgroundColor: colors.bg },
312
+ viewportOptions: { backgroundColor: colors.bg },
313
+ contentOptions: { backgroundColor: colors.bg },
314
+ scrollbarOptions: {
315
+ trackOptions: {
316
+ foregroundColor: colors.primary,
317
+ backgroundColor: colors.border,
318
+ },
319
+ },
320
+ }}
321
+ >
322
+ <box style={{ paddingLeft: 1, paddingRight: 1, flexDirection: "column" }}>
323
+ {/* Name */}
324
+ <text fg={colors.primary}>
325
+ <strong>{skill.name}</strong>
326
+ </text>
327
+
328
+ {/* Description */}
329
+ <text fg={colors.text}>{description}</text>
330
+ <text>{" "}</text>
331
+
332
+ {/* Categories */}
333
+ {categories ? (
334
+ <box style={{ flexDirection: "row", height: 1 }}>
335
+ <text fg={colors.textDim}>Categories: </text>
336
+ <text fg={colors.secondary}>{categories}</text>
337
+ </box>
338
+ ) : null}
339
+
340
+ {/* Capabilities */}
341
+ {capabilities ? (
342
+ <box style={{ flexDirection: "row", height: 1 }}>
343
+ <text fg={colors.textDim}>Capabilities: </text>
344
+ <text fg={colors.secondary}>{capabilities}</text>
345
+ </box>
346
+ ) : null}
347
+
348
+ {/* Keywords */}
349
+ {keywords ? (
350
+ <box style={{ flexDirection: "row", height: 1 }}>
351
+ <text fg={colors.textDim}>Keywords: </text>
352
+ <text fg={colors.secondary}>{keywords}</text>
353
+ </box>
354
+ ) : null}
355
+
356
+ {/* Score (semantic search only) */}
357
+ {skill.score ? (
358
+ <box style={{ flexDirection: "row", height: 1 }}>
359
+ <text fg={colors.textDim}>Score: </text>
360
+ <text fg={colors.success}>{skill.score.toFixed(3)}</text>
361
+ </box>
362
+ ) : null}
363
+
364
+ {/* GitHub URL */}
365
+ {skill.githubUrl ? (
366
+ <box style={{ flexDirection: "row", height: 1 }}>
367
+ <text fg={colors.textDim}>GitHub: </text>
368
+ <text fg={colors.primary}>{skill.githubUrl}</text>
369
+ </box>
370
+ ) : null}
371
+
372
+ {/* Install command */}
373
+ {skill.installCommand ? (
374
+ <>
375
+ <text>{" "}</text>
376
+ <text fg={colors.textDim}>Install:</text>
377
+ <text fg={colors.success}> {skill.installCommand}</text>
378
+ </>
379
+ ) : null}
380
+
381
+ <text>{" "}</text>
382
+ <text fg={colors.textDim}>v=full detail i=install Tab=switch pane</text>
383
+ </box>
384
+ </scrollbox>
385
+ )
386
+ }
387
+
388
+ // ---------- Helpers ----------
389
+
390
+ /**
391
+ * Converts a catalog skill to an EnrichedSkill for the detail view.
392
+ * Since catalog skills don't have a local file, we provide a placeholder.
393
+ */
394
+ function catalogSkillToEnriched(skill: CatalogSkill): import("../store/types.js").EnrichedSkill {
395
+ return {
396
+ name: skill.name,
397
+ description: skill.summary || skill.description || "",
398
+ filePath: "", // No local file for catalog items
399
+ agents: [],
400
+ metadata: {
401
+ categories: skill.categories,
402
+ capabilities: skill.capabilities,
403
+ keywords: skill.keywords,
404
+ githubUrl: skill.githubUrl,
405
+ installCommand: skill.installCommand,
406
+ urlPath: skill.urlPath,
407
+ },
408
+ lock: skill.githubUrl
409
+ ? {
410
+ source: skill.githubUrl,
411
+ sourceType: "github" as const,
412
+ originalUrl: skill.githubUrl,
413
+ skillFolderHash: "",
414
+ installedAt: "",
415
+ updatedAt: "",
416
+ }
417
+ : undefined,
418
+ }
419
+ }