@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.
- package/bin/skillsgate-tui +28 -0
- package/bunfig.toml +3 -0
- package/package.json +24 -0
- package/src/app.tsx +18 -0
- package/src/components/agent-filter.tsx +162 -0
- package/src/components/confirm-dialog.tsx +56 -0
- package/src/components/help-overlay.tsx +101 -0
- package/src/components/layout.tsx +272 -0
- package/src/components/search-input.tsx +48 -0
- package/src/components/skill-list-item.tsx +45 -0
- package/src/components/skill-list.tsx +245 -0
- package/src/components/status-bar.tsx +34 -0
- package/src/data/api-client.ts +151 -0
- package/src/data/use-agents.ts +41 -0
- package/src/data/use-auth.ts +136 -0
- package/src/data/use-favorites.ts +147 -0
- package/src/data/use-installed-skills.ts +128 -0
- package/src/data/use-search.ts +118 -0
- package/src/data/use-skill-actions.ts +333 -0
- package/src/db/context.tsx +38 -0
- package/src/db/index.ts +19 -0
- package/src/db/migrations.ts +72 -0
- package/src/db/servers.ts +154 -0
- package/src/db/settings.ts +43 -0
- package/src/db/skills.ts +138 -0
- package/src/db/ssh.ts +319 -0
- package/src/index.tsx +37 -0
- package/src/store/context.tsx +26 -0
- package/src/store/reducers.ts +126 -0
- package/src/store/types.ts +124 -0
- package/src/utils/colors.ts +42 -0
- package/src/views/add-server.tsx +240 -0
- package/src/views/discover.tsx +419 -0
- package/src/views/favorites.tsx +358 -0
- package/src/views/home.tsx +218 -0
- package/src/views/login.tsx +202 -0
- package/src/views/server-skills.tsx +269 -0
- package/src/views/servers.tsx +449 -0
- package/src/views/settings.tsx +185 -0
- package/src/views/skill-detail.tsx +497 -0
- 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
|
+
}
|