@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,449 @@
|
|
|
1
|
+
import { useState, useCallback, useEffect } from "react"
|
|
2
|
+
import { useKeyboard } from "@opentui/react"
|
|
3
|
+
import { useStore, useDispatch } from "../store/context.js"
|
|
4
|
+
import { useDb } from "../db/context.js"
|
|
5
|
+
import { ConfirmDialog } from "../components/confirm-dialog.js"
|
|
6
|
+
import { testConnection, syncRemoteServer } from "../db/ssh.js"
|
|
7
|
+
import { colors } from "../utils/colors.js"
|
|
8
|
+
import type { RemoteServer } from "../db/servers.js"
|
|
9
|
+
|
|
10
|
+
interface ServersViewProps {
|
|
11
|
+
onServerCountChange: (count: number) => void
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type PendingAction = {
|
|
15
|
+
type: "delete" | "sync" | "test"
|
|
16
|
+
server: RemoteServer
|
|
17
|
+
} | null
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Compute a human-readable relative time string from an ISO date string.
|
|
21
|
+
*/
|
|
22
|
+
function relativeTime(isoDate: string | null): string {
|
|
23
|
+
if (!isoDate) return "never"
|
|
24
|
+
const diffMs = Date.now() - new Date(isoDate).getTime()
|
|
25
|
+
const seconds = Math.floor(diffMs / 1000)
|
|
26
|
+
if (seconds < 60) return `${seconds}s ago`
|
|
27
|
+
const minutes = Math.floor(seconds / 60)
|
|
28
|
+
if (minutes < 60) return `${minutes}m ago`
|
|
29
|
+
const hours = Math.floor(minutes / 60)
|
|
30
|
+
if (hours < 24) return `${hours}h ago`
|
|
31
|
+
const days = Math.floor(hours / 24)
|
|
32
|
+
return `${days}d ago`
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function ServersView({ onServerCountChange }: ServersViewProps) {
|
|
36
|
+
const state = useStore()
|
|
37
|
+
const dispatch = useDispatch()
|
|
38
|
+
const { servers, skills } = useDb()
|
|
39
|
+
|
|
40
|
+
const [serverList, setServerList] = useState<RemoteServer[]>(() => servers.list())
|
|
41
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
42
|
+
const [pendingAction, setPendingAction] = useState<PendingAction>(null)
|
|
43
|
+
const [syncing, setSyncing] = useState(false)
|
|
44
|
+
const [syncLog, setSyncLog] = useState<string[] | null>(null)
|
|
45
|
+
const [testing, setTesting] = useState(false)
|
|
46
|
+
|
|
47
|
+
const refreshList = useCallback(() => {
|
|
48
|
+
const list = servers.list()
|
|
49
|
+
setServerList(list)
|
|
50
|
+
onServerCountChange(list.length)
|
|
51
|
+
}, [servers, onServerCountChange])
|
|
52
|
+
|
|
53
|
+
// Refresh when coming back to this view
|
|
54
|
+
useEffect(() => {
|
|
55
|
+
refreshList()
|
|
56
|
+
}, [])
|
|
57
|
+
|
|
58
|
+
// Get skill counts for each server
|
|
59
|
+
const skillCounts = new Map<string, number>()
|
|
60
|
+
for (const srv of serverList) {
|
|
61
|
+
skillCounts.set(srv.id, servers.skillCount(srv.id))
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const selectedServer = serverList[selectedIndex] ?? null
|
|
65
|
+
|
|
66
|
+
useKeyboard((key) => {
|
|
67
|
+
if (state.activeView !== "servers") return
|
|
68
|
+
if (state.showHelp) return
|
|
69
|
+
if (pendingAction) return
|
|
70
|
+
if (syncing || testing) return
|
|
71
|
+
|
|
72
|
+
// j/k or arrow keys
|
|
73
|
+
if (key.name === "up" || (key.name === "k" && !key.ctrl)) {
|
|
74
|
+
setSelectedIndex((i) => Math.max(0, i - 1))
|
|
75
|
+
}
|
|
76
|
+
if (key.name === "down" || (key.name === "j" && !key.ctrl)) {
|
|
77
|
+
setSelectedIndex((i) => Math.min(serverList.length - 1, i + 1))
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// g = first, G = last
|
|
81
|
+
if (key.name === "g" && !key.shift) {
|
|
82
|
+
setSelectedIndex(0)
|
|
83
|
+
}
|
|
84
|
+
if (key.name === "g" && key.shift) {
|
|
85
|
+
setSelectedIndex(Math.max(0, serverList.length - 1))
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// a = add new server
|
|
89
|
+
if (key.name === "a") {
|
|
90
|
+
dispatch({ type: "NAVIGATE", view: "add-server" })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// e = edit selected server
|
|
95
|
+
if (key.name === "e" && selectedServer) {
|
|
96
|
+
dispatch({ type: "SET_SELECTED_SERVER", serverId: selectedServer.id })
|
|
97
|
+
dispatch({ type: "NAVIGATE", view: "edit-server" })
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// d = delete selected server (with confirm)
|
|
102
|
+
if (key.name === "d" && selectedServer) {
|
|
103
|
+
setPendingAction({ type: "delete", server: selectedServer })
|
|
104
|
+
return
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// t = test connection
|
|
108
|
+
if (key.name === "t" && selectedServer) {
|
|
109
|
+
handleTestConnection(selectedServer)
|
|
110
|
+
return
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// S = sync selected server
|
|
114
|
+
if (key.name === "S" && selectedServer) {
|
|
115
|
+
handleSync(selectedServer)
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Enter = browse server's skills
|
|
120
|
+
if (key.name === "return" && selectedServer) {
|
|
121
|
+
const count = skillCounts.get(selectedServer.id) ?? 0
|
|
122
|
+
if (count === 0 && !selectedServer.lastSyncAt) {
|
|
123
|
+
dispatch({
|
|
124
|
+
type: "SHOW_NOTIFICATION",
|
|
125
|
+
notification: { type: "info", message: "Sync server first (S) to discover remote skills" },
|
|
126
|
+
})
|
|
127
|
+
return
|
|
128
|
+
}
|
|
129
|
+
dispatch({ type: "SET_SELECTED_SERVER", serverId: selectedServer.id })
|
|
130
|
+
dispatch({ type: "NAVIGATE", view: "server-skills" })
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
const handleTestConnection = useCallback(async (server: RemoteServer) => {
|
|
136
|
+
setTesting(true)
|
|
137
|
+
dispatch({
|
|
138
|
+
type: "SHOW_NOTIFICATION",
|
|
139
|
+
notification: { type: "info", message: `Testing connection to ${server.host}...` },
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const result = await testConnection(server)
|
|
143
|
+
|
|
144
|
+
setTesting(false)
|
|
145
|
+
if (result.ok) {
|
|
146
|
+
dispatch({
|
|
147
|
+
type: "SHOW_NOTIFICATION",
|
|
148
|
+
notification: { type: "success", message: `Connection to ${server.host} successful` },
|
|
149
|
+
})
|
|
150
|
+
} else {
|
|
151
|
+
dispatch({
|
|
152
|
+
type: "SHOW_NOTIFICATION",
|
|
153
|
+
notification: { type: "error", message: `Connection failed: ${result.error}` },
|
|
154
|
+
})
|
|
155
|
+
}
|
|
156
|
+
}, [dispatch])
|
|
157
|
+
|
|
158
|
+
const handleSync = useCallback(async (server: RemoteServer) => {
|
|
159
|
+
setSyncing(true)
|
|
160
|
+
setSyncLog([`Connecting to ${server.username}@${server.host}...`])
|
|
161
|
+
dispatch({
|
|
162
|
+
type: "SHOW_NOTIFICATION",
|
|
163
|
+
notification: { type: "info", message: `Syncing ${server.label}...` },
|
|
164
|
+
})
|
|
165
|
+
|
|
166
|
+
const result = await syncRemoteServer(server, servers, skills)
|
|
167
|
+
|
|
168
|
+
setSyncLog(result.log)
|
|
169
|
+
setSyncing(false)
|
|
170
|
+
refreshList()
|
|
171
|
+
|
|
172
|
+
if (result.total > 0 || result.removed > 0) {
|
|
173
|
+
dispatch({
|
|
174
|
+
type: "SHOW_NOTIFICATION",
|
|
175
|
+
notification: {
|
|
176
|
+
type: "success",
|
|
177
|
+
message: `Synced ${server.label}: ${result.added} new, ${result.updated} updated, ${result.removed} removed`,
|
|
178
|
+
},
|
|
179
|
+
})
|
|
180
|
+
} else if (result.log.some((l) => l.includes("failed"))) {
|
|
181
|
+
dispatch({
|
|
182
|
+
type: "SHOW_NOTIFICATION",
|
|
183
|
+
notification: { type: "error", message: `Sync failed for ${server.label}` },
|
|
184
|
+
})
|
|
185
|
+
} else {
|
|
186
|
+
dispatch({
|
|
187
|
+
type: "SHOW_NOTIFICATION",
|
|
188
|
+
notification: { type: "info", message: `No skills found on ${server.label}` },
|
|
189
|
+
})
|
|
190
|
+
}
|
|
191
|
+
}, [servers, skills, dispatch, refreshList])
|
|
192
|
+
|
|
193
|
+
// Confirm dialog for delete
|
|
194
|
+
if (pendingAction?.type === "delete") {
|
|
195
|
+
return (
|
|
196
|
+
<ConfirmDialog
|
|
197
|
+
message={`Delete server "${pendingAction.server.label}"? This will remove all cached skills.`}
|
|
198
|
+
onConfirm={() => {
|
|
199
|
+
servers.delete(pendingAction.server.id)
|
|
200
|
+
setPendingAction(null)
|
|
201
|
+
refreshList()
|
|
202
|
+
if (selectedIndex >= serverList.length - 1 && selectedIndex > 0) {
|
|
203
|
+
setSelectedIndex(selectedIndex - 1)
|
|
204
|
+
}
|
|
205
|
+
dispatch({
|
|
206
|
+
type: "SHOW_NOTIFICATION",
|
|
207
|
+
notification: { type: "success", message: `Deleted "${pendingAction.server.label}"` },
|
|
208
|
+
})
|
|
209
|
+
}}
|
|
210
|
+
onCancel={() => setPendingAction(null)}
|
|
211
|
+
/>
|
|
212
|
+
)
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<box style={{ flexDirection: "column", width: "100%", flexGrow: 1 }}>
|
|
217
|
+
{/* Action bar */}
|
|
218
|
+
<box
|
|
219
|
+
style={{
|
|
220
|
+
height: 1,
|
|
221
|
+
width: "100%",
|
|
222
|
+
paddingLeft: 1,
|
|
223
|
+
backgroundColor: colors.bgAlt,
|
|
224
|
+
flexDirection: "row",
|
|
225
|
+
}}
|
|
226
|
+
>
|
|
227
|
+
<text fg={colors.textDim}>
|
|
228
|
+
{serverList.length} server{serverList.length !== 1 ? "s" : ""}
|
|
229
|
+
{" "}
|
|
230
|
+
{syncing ? "syncing..." : testing ? "testing..." : ""}
|
|
231
|
+
</text>
|
|
232
|
+
</box>
|
|
233
|
+
|
|
234
|
+
{/* Two-column layout: server list (left) | detail/log (right) */}
|
|
235
|
+
<box style={{ flexDirection: "row", flexGrow: 1, width: "100%" }}>
|
|
236
|
+
{/* LEFT: Server list */}
|
|
237
|
+
<box
|
|
238
|
+
style={{
|
|
239
|
+
width: "50%",
|
|
240
|
+
borderRight: true,
|
|
241
|
+
borderColor: colors.border,
|
|
242
|
+
flexDirection: "column",
|
|
243
|
+
}}
|
|
244
|
+
>
|
|
245
|
+
{/* Header */}
|
|
246
|
+
<box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
|
|
247
|
+
<text fg={colors.textDim}>SERVERS</text>
|
|
248
|
+
</box>
|
|
249
|
+
|
|
250
|
+
{/* Add server shortcut */}
|
|
251
|
+
<box style={{ height: 1, paddingLeft: 1 }}>
|
|
252
|
+
<text fg={colors.primary}>[+] Add Server (a)</text>
|
|
253
|
+
</box>
|
|
254
|
+
|
|
255
|
+
{serverList.length === 0 ? (
|
|
256
|
+
<box style={{ padding: 1 }}>
|
|
257
|
+
<text fg={colors.textDim}>
|
|
258
|
+
No remote servers configured. Press 'a' to add one.
|
|
259
|
+
</text>
|
|
260
|
+
</box>
|
|
261
|
+
) : (
|
|
262
|
+
<scrollbox
|
|
263
|
+
focused={state.activeView === "servers" && !state.showHelp}
|
|
264
|
+
style={{
|
|
265
|
+
width: "100%",
|
|
266
|
+
flexGrow: 1,
|
|
267
|
+
rootOptions: { backgroundColor: colors.bg },
|
|
268
|
+
viewportOptions: { backgroundColor: colors.bg },
|
|
269
|
+
contentOptions: { backgroundColor: colors.bg },
|
|
270
|
+
scrollbarOptions: {
|
|
271
|
+
trackOptions: {
|
|
272
|
+
foregroundColor: colors.primary,
|
|
273
|
+
backgroundColor: colors.border,
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
}}
|
|
277
|
+
>
|
|
278
|
+
{serverList.map((srv, i) => {
|
|
279
|
+
const count = skillCounts.get(srv.id) ?? 0
|
|
280
|
+
const hasError = !!srv.lastSyncError
|
|
281
|
+
const neverSynced = !srv.lastSyncAt && !srv.lastSyncError
|
|
282
|
+
const statusColor = hasError
|
|
283
|
+
? colors.error
|
|
284
|
+
: neverSynced
|
|
285
|
+
? colors.warning
|
|
286
|
+
: colors.success
|
|
287
|
+
const statusChar = hasError ? "x" : neverSynced ? "?" : "o"
|
|
288
|
+
const syncInfo = hasError
|
|
289
|
+
? "error"
|
|
290
|
+
: srv.lastSyncAt
|
|
291
|
+
? relativeTime(srv.lastSyncAt)
|
|
292
|
+
: "never"
|
|
293
|
+
|
|
294
|
+
return (
|
|
295
|
+
<box
|
|
296
|
+
key={srv.id}
|
|
297
|
+
style={{
|
|
298
|
+
width: "100%",
|
|
299
|
+
paddingLeft: 1,
|
|
300
|
+
paddingRight: 1,
|
|
301
|
+
flexDirection: "row",
|
|
302
|
+
backgroundColor: i === selectedIndex ? colors.bgAlt : "transparent",
|
|
303
|
+
}}
|
|
304
|
+
>
|
|
305
|
+
<text fg={statusColor}>[{statusChar}]</text>
|
|
306
|
+
<text fg={i === selectedIndex ? colors.primary : colors.text}>
|
|
307
|
+
{" "}{srv.label}
|
|
308
|
+
</text>
|
|
309
|
+
<text fg={colors.textDim}>
|
|
310
|
+
{" "}{srv.username}@{srv.host}
|
|
311
|
+
</text>
|
|
312
|
+
<text fg={colors.textDim}>
|
|
313
|
+
{" "}{count > 0 ? `${count} skills` : "--"}
|
|
314
|
+
</text>
|
|
315
|
+
<text fg={colors.textDim}>
|
|
316
|
+
{" "}{syncInfo}
|
|
317
|
+
</text>
|
|
318
|
+
</box>
|
|
319
|
+
)
|
|
320
|
+
})}
|
|
321
|
+
</scrollbox>
|
|
322
|
+
)}
|
|
323
|
+
|
|
324
|
+
{/* Bottom shortcut hints */}
|
|
325
|
+
<box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
|
|
326
|
+
<text fg={colors.textDim}>
|
|
327
|
+
S=sync Enter=browse a=add e=edit d=delete t=test
|
|
328
|
+
</text>
|
|
329
|
+
</box>
|
|
330
|
+
</box>
|
|
331
|
+
|
|
332
|
+
{/* RIGHT: Server detail / sync log */}
|
|
333
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
334
|
+
{syncLog ? (
|
|
335
|
+
<SyncLogPanel log={syncLog} onDismiss={() => setSyncLog(null)} />
|
|
336
|
+
) : selectedServer ? (
|
|
337
|
+
<ServerDetailPanel
|
|
338
|
+
server={selectedServer}
|
|
339
|
+
skillCount={skillCounts.get(selectedServer.id) ?? 0}
|
|
340
|
+
/>
|
|
341
|
+
) : (
|
|
342
|
+
<box style={{ padding: 1 }}>
|
|
343
|
+
<text fg={colors.textDim}>No server selected</text>
|
|
344
|
+
</box>
|
|
345
|
+
)}
|
|
346
|
+
</box>
|
|
347
|
+
</box>
|
|
348
|
+
</box>
|
|
349
|
+
)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------- Server Detail Panel ----------
|
|
353
|
+
|
|
354
|
+
interface ServerDetailPanelProps {
|
|
355
|
+
server: RemoteServer
|
|
356
|
+
skillCount: number
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function ServerDetailPanel({ server, skillCount }: ServerDetailPanelProps) {
|
|
360
|
+
return (
|
|
361
|
+
<box style={{ paddingLeft: 1, paddingRight: 1, paddingTop: 0, flexDirection: "column" }}>
|
|
362
|
+
<text fg={colors.primary}>
|
|
363
|
+
<strong>{server.label}</strong>
|
|
364
|
+
</text>
|
|
365
|
+
<text>{" "}</text>
|
|
366
|
+
|
|
367
|
+
<text fg={colors.textDim}>Host</text>
|
|
368
|
+
<text fg={colors.text}> {server.host}:{server.port}</text>
|
|
369
|
+
<text>{" "}</text>
|
|
370
|
+
|
|
371
|
+
<text fg={colors.textDim}>Username</text>
|
|
372
|
+
<text fg={colors.text}> {server.username}</text>
|
|
373
|
+
<text>{" "}</text>
|
|
374
|
+
|
|
375
|
+
<text fg={colors.textDim}>Skills Base Path</text>
|
|
376
|
+
<text fg={colors.text}> {server.skillsBasePath}</text>
|
|
377
|
+
<text>{" "}</text>
|
|
378
|
+
|
|
379
|
+
<text fg={colors.textDim}>SSH Key</text>
|
|
380
|
+
<text fg={colors.text}> {server.sshKeyPath ?? "(auto-discover)"}</text>
|
|
381
|
+
<text>{" "}</text>
|
|
382
|
+
|
|
383
|
+
<text fg={colors.textDim}>Skills Cached</text>
|
|
384
|
+
<text fg={colors.text}> {skillCount}</text>
|
|
385
|
+
<text>{" "}</text>
|
|
386
|
+
|
|
387
|
+
<text fg={colors.textDim}>Last Sync</text>
|
|
388
|
+
<text fg={colors.text}> {server.lastSyncAt ? relativeTime(server.lastSyncAt) : "never"}</text>
|
|
389
|
+
<text>{" "}</text>
|
|
390
|
+
|
|
391
|
+
{server.lastSyncError ? (
|
|
392
|
+
<>
|
|
393
|
+
<text fg={colors.textDim}>Last Error</text>
|
|
394
|
+
<text fg={colors.error}> {server.lastSyncError}</text>
|
|
395
|
+
<text>{" "}</text>
|
|
396
|
+
</>
|
|
397
|
+
) : null}
|
|
398
|
+
|
|
399
|
+
<text fg={colors.border}>---</text>
|
|
400
|
+
<text fg={colors.textDim}>S=sync t=test e=edit d=delete Enter=browse skills</text>
|
|
401
|
+
</box>
|
|
402
|
+
)
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
// ---------- Sync Log Panel ----------
|
|
406
|
+
|
|
407
|
+
interface SyncLogPanelProps {
|
|
408
|
+
log: string[]
|
|
409
|
+
onDismiss: () => void
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
function SyncLogPanel({ log, onDismiss }: SyncLogPanelProps) {
|
|
413
|
+
useKeyboard((key) => {
|
|
414
|
+
if (key.name === "escape" || key.name === "return") {
|
|
415
|
+
onDismiss()
|
|
416
|
+
}
|
|
417
|
+
})
|
|
418
|
+
|
|
419
|
+
return (
|
|
420
|
+
<box
|
|
421
|
+
style={{
|
|
422
|
+
flexDirection: "column",
|
|
423
|
+
flexGrow: 1,
|
|
424
|
+
paddingLeft: 1,
|
|
425
|
+
paddingRight: 1,
|
|
426
|
+
border: true,
|
|
427
|
+
borderColor: colors.primary,
|
|
428
|
+
}}
|
|
429
|
+
title="Sync Log"
|
|
430
|
+
>
|
|
431
|
+
{log.map((line, i) => (
|
|
432
|
+
<text
|
|
433
|
+
key={i}
|
|
434
|
+
fg={
|
|
435
|
+
line.includes("failed") || line.includes("Error")
|
|
436
|
+
? colors.error
|
|
437
|
+
: line.includes("Synced:") || line.includes("Found")
|
|
438
|
+
? colors.success
|
|
439
|
+
: colors.text
|
|
440
|
+
}
|
|
441
|
+
>
|
|
442
|
+
{line}
|
|
443
|
+
</text>
|
|
444
|
+
))}
|
|
445
|
+
<text>{" "}</text>
|
|
446
|
+
<text fg={colors.textDim}>Press Enter or Esc to dismiss</text>
|
|
447
|
+
</box>
|
|
448
|
+
)
|
|
449
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import { useState, useCallback } from "react"
|
|
2
|
+
import { useKeyboard } from "@opentui/react"
|
|
3
|
+
import { useStore, useDispatch } from "../store/context.js"
|
|
4
|
+
import { useDb } from "../db/context.js"
|
|
5
|
+
import { colors } from "../utils/colors.js"
|
|
6
|
+
|
|
7
|
+
interface SettingDef {
|
|
8
|
+
key: string
|
|
9
|
+
label: string
|
|
10
|
+
type: "select" | "toggle"
|
|
11
|
+
options?: string[]
|
|
12
|
+
defaultValue: string | boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const SETTING_DEFS: SettingDef[] = [
|
|
16
|
+
{
|
|
17
|
+
key: "install.scope",
|
|
18
|
+
label: "Default install scope",
|
|
19
|
+
type: "select",
|
|
20
|
+
options: ["global", "project"],
|
|
21
|
+
defaultValue: "global",
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
key: "install.method",
|
|
25
|
+
label: "Default install method",
|
|
26
|
+
type: "select",
|
|
27
|
+
options: ["symlink", "copy"],
|
|
28
|
+
defaultValue: "symlink",
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
key: "ui.theme",
|
|
32
|
+
label: "Theme",
|
|
33
|
+
type: "select",
|
|
34
|
+
options: ["dark", "light", "system"],
|
|
35
|
+
defaultValue: "dark",
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: "search.preferSemantic",
|
|
39
|
+
label: "Prefer semantic search",
|
|
40
|
+
type: "toggle",
|
|
41
|
+
defaultValue: true,
|
|
42
|
+
},
|
|
43
|
+
{
|
|
44
|
+
key: "telemetry.enabled",
|
|
45
|
+
label: "Anonymous telemetry",
|
|
46
|
+
type: "toggle",
|
|
47
|
+
defaultValue: true,
|
|
48
|
+
},
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Settings view: displays a list of key-value settings from SQLite.
|
|
53
|
+
* Navigate with j/k, press Enter to toggle/cycle values.
|
|
54
|
+
* Changes are saved immediately.
|
|
55
|
+
*/
|
|
56
|
+
export function SettingsView() {
|
|
57
|
+
const state = useStore()
|
|
58
|
+
const dispatch = useDispatch()
|
|
59
|
+
const { settings } = useDb()
|
|
60
|
+
|
|
61
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
62
|
+
// Force re-render after changes
|
|
63
|
+
const [version, setVersion] = useState(0)
|
|
64
|
+
|
|
65
|
+
const currentValues = SETTING_DEFS.map((def) => {
|
|
66
|
+
const stored = settings.get(def.key, def.defaultValue)
|
|
67
|
+
return stored
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const handleToggle = useCallback((def: SettingDef, currentValue: unknown) => {
|
|
71
|
+
if (def.type === "toggle") {
|
|
72
|
+
const newVal = !currentValue
|
|
73
|
+
settings.set(def.key, newVal)
|
|
74
|
+
setVersion((v) => v + 1)
|
|
75
|
+
dispatch({
|
|
76
|
+
type: "SHOW_NOTIFICATION",
|
|
77
|
+
notification: { type: "info", message: `${def.label}: ${newVal ? "enabled" : "disabled"}` },
|
|
78
|
+
})
|
|
79
|
+
} else if (def.type === "select" && def.options) {
|
|
80
|
+
const currentStr = String(currentValue)
|
|
81
|
+
const idx = def.options.indexOf(currentStr)
|
|
82
|
+
const nextIdx = (idx + 1) % def.options.length
|
|
83
|
+
const newVal = def.options[nextIdx]
|
|
84
|
+
settings.set(def.key, newVal)
|
|
85
|
+
setVersion((v) => v + 1)
|
|
86
|
+
dispatch({
|
|
87
|
+
type: "SHOW_NOTIFICATION",
|
|
88
|
+
notification: { type: "info", message: `${def.label}: ${newVal}` },
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
}, [settings, dispatch])
|
|
92
|
+
|
|
93
|
+
useKeyboard((key) => {
|
|
94
|
+
if (state.activeView !== "settings") return
|
|
95
|
+
if (state.showHelp) return
|
|
96
|
+
|
|
97
|
+
// j/k or arrow keys
|
|
98
|
+
if (key.name === "up" || (key.name === "k" && !key.ctrl)) {
|
|
99
|
+
setSelectedIndex((i) => Math.max(0, i - 1))
|
|
100
|
+
}
|
|
101
|
+
if (key.name === "down" || (key.name === "j" && !key.ctrl)) {
|
|
102
|
+
setSelectedIndex((i) => Math.min(SETTING_DEFS.length - 1, i + 1))
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Enter to toggle/cycle
|
|
106
|
+
if (key.name === "return") {
|
|
107
|
+
const def = SETTING_DEFS[selectedIndex]
|
|
108
|
+
if (def) {
|
|
109
|
+
handleToggle(def, currentValues[selectedIndex])
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Esc handled by layout
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
return (
|
|
117
|
+
<box style={{ flexDirection: "column", width: "100%", flexGrow: 1 }}>
|
|
118
|
+
{/* Header */}
|
|
119
|
+
<box
|
|
120
|
+
style={{
|
|
121
|
+
height: 1,
|
|
122
|
+
width: "100%",
|
|
123
|
+
paddingLeft: 1,
|
|
124
|
+
backgroundColor: colors.bgAlt,
|
|
125
|
+
}}
|
|
126
|
+
>
|
|
127
|
+
<text fg={colors.primary}>
|
|
128
|
+
<strong>Settings</strong>
|
|
129
|
+
</text>
|
|
130
|
+
</box>
|
|
131
|
+
|
|
132
|
+
<box style={{ flexDirection: "column", paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}>
|
|
133
|
+
{SETTING_DEFS.map((def, i) => {
|
|
134
|
+
const val = currentValues[i]
|
|
135
|
+
const isSelected = i === selectedIndex
|
|
136
|
+
let displayValue: string
|
|
137
|
+
|
|
138
|
+
if (def.type === "toggle") {
|
|
139
|
+
displayValue = val ? "ON" : "OFF"
|
|
140
|
+
} else {
|
|
141
|
+
displayValue = String(val)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const valueColor = def.type === "toggle"
|
|
145
|
+
? (val ? colors.success : colors.error)
|
|
146
|
+
: colors.secondary
|
|
147
|
+
|
|
148
|
+
return (
|
|
149
|
+
<box
|
|
150
|
+
key={def.key}
|
|
151
|
+
style={{
|
|
152
|
+
width: "100%",
|
|
153
|
+
height: 1,
|
|
154
|
+
flexDirection: "row",
|
|
155
|
+
paddingLeft: 1,
|
|
156
|
+
backgroundColor: isSelected ? colors.bgAlt : "transparent",
|
|
157
|
+
}}
|
|
158
|
+
>
|
|
159
|
+
<text fg={isSelected ? colors.primary : colors.text} style={{ width: 30 }}>
|
|
160
|
+
{def.label}
|
|
161
|
+
</text>
|
|
162
|
+
<text fg={valueColor}>
|
|
163
|
+
{" "}{displayValue}
|
|
164
|
+
</text>
|
|
165
|
+
{isSelected && def.type === "select" ? (
|
|
166
|
+
<text fg={colors.textDim}>
|
|
167
|
+
{" "}(Enter to cycle: {def.options?.join(" > ")})
|
|
168
|
+
</text>
|
|
169
|
+
) : null}
|
|
170
|
+
{isSelected && def.type === "toggle" ? (
|
|
171
|
+
<text fg={colors.textDim}>
|
|
172
|
+
{" "}(Enter to toggle)
|
|
173
|
+
</text>
|
|
174
|
+
) : null}
|
|
175
|
+
</box>
|
|
176
|
+
)
|
|
177
|
+
})}
|
|
178
|
+
</box>
|
|
179
|
+
|
|
180
|
+
<box style={{ paddingLeft: 2, paddingTop: 1 }}>
|
|
181
|
+
<text fg={colors.textDim}>j/k=navigate Enter=change Esc=back</text>
|
|
182
|
+
</box>
|
|
183
|
+
</box>
|
|
184
|
+
)
|
|
185
|
+
}
|