@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,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
+ }