@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,202 @@
|
|
|
1
|
+
import { useState, useCallback } from "react"
|
|
2
|
+
import { useKeyboard } from "@opentui/react"
|
|
3
|
+
import { useStore, useDispatch } from "../store/context.js"
|
|
4
|
+
import { useAuth } from "../data/use-auth.js"
|
|
5
|
+
import { API_BASE_URL } from "../../../cli/src/constants.js"
|
|
6
|
+
import { colors } from "../utils/colors.js"
|
|
7
|
+
|
|
8
|
+
type LoginStep = "prompt" | "code" | "exchanging"
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Login view implementing the device code flow:
|
|
12
|
+
* 1. Show instructions with the auth URL
|
|
13
|
+
* 2. Prompt to open browser (y/n)
|
|
14
|
+
* 3. Show input for device code (XXXX-XXXX)
|
|
15
|
+
* 4. Exchange code for token, save auth, navigate back
|
|
16
|
+
*/
|
|
17
|
+
export function LoginView() {
|
|
18
|
+
const state = useStore()
|
|
19
|
+
const dispatch = useDispatch()
|
|
20
|
+
const { auth, login, logout } = useAuth()
|
|
21
|
+
|
|
22
|
+
const [step, setStep] = useState<LoginStep>("prompt")
|
|
23
|
+
const [error, setError] = useState<string | null>(null)
|
|
24
|
+
|
|
25
|
+
const authUrl = `${API_BASE_URL}/cli/auth`
|
|
26
|
+
|
|
27
|
+
function openBrowser() {
|
|
28
|
+
try {
|
|
29
|
+
const { exec } = require("node:child_process")
|
|
30
|
+
const cmd = process.platform === "darwin" ? "open" : "xdg-open"
|
|
31
|
+
exec(`${cmd} "${authUrl}"`)
|
|
32
|
+
dispatch({
|
|
33
|
+
type: "SHOW_NOTIFICATION",
|
|
34
|
+
notification: { type: "info", message: "Opening browser..." },
|
|
35
|
+
})
|
|
36
|
+
} catch {
|
|
37
|
+
// Best effort
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Handle keyboard input
|
|
42
|
+
useKeyboard((key) => {
|
|
43
|
+
if (state.activeView !== "login") return
|
|
44
|
+
if (state.showHelp) return
|
|
45
|
+
|
|
46
|
+
// Esc to go back at any step
|
|
47
|
+
if (key.name === "escape") {
|
|
48
|
+
dispatch({ type: "GO_BACK" })
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (step === "prompt") {
|
|
53
|
+
// "r" to re-login (clear old auth, open browser, go to code step)
|
|
54
|
+
if (key.name === "r") {
|
|
55
|
+
logout()
|
|
56
|
+
openBrowser()
|
|
57
|
+
setStep("code")
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// "o" to logout only (no re-login)
|
|
62
|
+
if (key.name === "o") {
|
|
63
|
+
logout()
|
|
64
|
+
dispatch({
|
|
65
|
+
type: "SHOW_NOTIFICATION",
|
|
66
|
+
notification: { type: "success", message: "Signed out" },
|
|
67
|
+
})
|
|
68
|
+
dispatch({ type: "GO_BACK" })
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// "y" to open browser and proceed to code input
|
|
73
|
+
if (key.name === "y") {
|
|
74
|
+
openBrowser()
|
|
75
|
+
setStep("code")
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// "n" to skip browser, go straight to code input
|
|
80
|
+
if (key.name === "n") {
|
|
81
|
+
setStep("code")
|
|
82
|
+
return
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
const handleCodeSubmit = useCallback(async (value: string) => {
|
|
88
|
+
const code = value.trim()
|
|
89
|
+
if (!code) return
|
|
90
|
+
|
|
91
|
+
setStep("exchanging")
|
|
92
|
+
setError(null)
|
|
93
|
+
|
|
94
|
+
const errMsg = await login(code)
|
|
95
|
+
|
|
96
|
+
if (errMsg) {
|
|
97
|
+
setError(errMsg)
|
|
98
|
+
setStep("code") // Let user retry
|
|
99
|
+
} else {
|
|
100
|
+
// Success - navigate back
|
|
101
|
+
dispatch({
|
|
102
|
+
type: "SHOW_NOTIFICATION",
|
|
103
|
+
notification: { type: "success", message: "Logged in successfully!" },
|
|
104
|
+
})
|
|
105
|
+
dispatch({ type: "GO_BACK" })
|
|
106
|
+
}
|
|
107
|
+
}, [login, dispatch])
|
|
108
|
+
|
|
109
|
+
// Already logged in -- offer re-login or logout
|
|
110
|
+
if (auth && step === "prompt") {
|
|
111
|
+
return (
|
|
112
|
+
<box style={{ flexDirection: "column", padding: 2 }}>
|
|
113
|
+
<text fg={colors.success}>
|
|
114
|
+
Logged in as <strong>{auth.user.name}</strong> ({auth.user.email})
|
|
115
|
+
</text>
|
|
116
|
+
<text>{" "}</text>
|
|
117
|
+
<text fg={colors.text}>
|
|
118
|
+
If AI search isn't working, your session may have expired.
|
|
119
|
+
</text>
|
|
120
|
+
<text>{" "}</text>
|
|
121
|
+
<text fg={colors.primary}>r</text>
|
|
122
|
+
<text fg={colors.text}> Re-login with a fresh token</text>
|
|
123
|
+
<text fg={colors.primary}>o</text>
|
|
124
|
+
<text fg={colors.text}> Sign out</text>
|
|
125
|
+
<text fg={colors.textDim}>Esc</text>
|
|
126
|
+
<text fg={colors.text}> Go back</text>
|
|
127
|
+
</box>
|
|
128
|
+
)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return (
|
|
132
|
+
<box style={{ flexDirection: "column", padding: 2 }}>
|
|
133
|
+
{/* Title */}
|
|
134
|
+
<text fg={colors.primary}>
|
|
135
|
+
<strong>Sign in to SkillsGate</strong>
|
|
136
|
+
</text>
|
|
137
|
+
<text>{" "}</text>
|
|
138
|
+
|
|
139
|
+
{/* Instructions */}
|
|
140
|
+
<text fg={colors.text}>
|
|
141
|
+
Visit the following URL in your browser to get a login code:
|
|
142
|
+
</text>
|
|
143
|
+
<text>{" "}</text>
|
|
144
|
+
<text fg={colors.primary}>
|
|
145
|
+
{authUrl}
|
|
146
|
+
</text>
|
|
147
|
+
<text>{" "}</text>
|
|
148
|
+
|
|
149
|
+
{step === "prompt" && (
|
|
150
|
+
<>
|
|
151
|
+
<text fg={colors.text}>
|
|
152
|
+
Open browser? <span fg={colors.textDim}>(y/n)</span>
|
|
153
|
+
</text>
|
|
154
|
+
</>
|
|
155
|
+
)}
|
|
156
|
+
|
|
157
|
+
{step === "code" && (
|
|
158
|
+
<>
|
|
159
|
+
<text fg={colors.text}>
|
|
160
|
+
Paste the code from the browser:
|
|
161
|
+
</text>
|
|
162
|
+
<text>{" "}</text>
|
|
163
|
+
<box
|
|
164
|
+
style={{
|
|
165
|
+
height: 3,
|
|
166
|
+
width: 40,
|
|
167
|
+
border: true,
|
|
168
|
+
borderColor: colors.primary,
|
|
169
|
+
paddingLeft: 1,
|
|
170
|
+
paddingRight: 1,
|
|
171
|
+
}}
|
|
172
|
+
title="Code"
|
|
173
|
+
>
|
|
174
|
+
<input
|
|
175
|
+
placeholder="XXXX-XXXX"
|
|
176
|
+
focused={state.activeView === "login" && step === "code" && !state.showHelp}
|
|
177
|
+
onSubmit={handleCodeSubmit}
|
|
178
|
+
/>
|
|
179
|
+
</box>
|
|
180
|
+
<text>{" "}</text>
|
|
181
|
+
<text fg={colors.textDim}>
|
|
182
|
+
Press Enter to submit, Esc to cancel
|
|
183
|
+
</text>
|
|
184
|
+
</>
|
|
185
|
+
)}
|
|
186
|
+
|
|
187
|
+
{step === "exchanging" && (
|
|
188
|
+
<text fg={colors.primary}>
|
|
189
|
+
Verifying code...
|
|
190
|
+
</text>
|
|
191
|
+
)}
|
|
192
|
+
|
|
193
|
+
{/* Error message */}
|
|
194
|
+
{error && (
|
|
195
|
+
<>
|
|
196
|
+
<text>{" "}</text>
|
|
197
|
+
<text fg={colors.error}>{error}</text>
|
|
198
|
+
</>
|
|
199
|
+
)}
|
|
200
|
+
</box>
|
|
201
|
+
)
|
|
202
|
+
}
|
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
import { useState, 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 { colors } from "../utils/colors.js"
|
|
6
|
+
import type { RemoteSkill } from "../db/skills.js"
|
|
7
|
+
|
|
8
|
+
interface ServerSkillsViewProps {
|
|
9
|
+
serverId: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Browse cached remote skills from a selected server.
|
|
14
|
+
* Two-column layout: skill list (left) | skill detail (right).
|
|
15
|
+
* 'i' to install a remote skill locally (placeholder), Esc to go back.
|
|
16
|
+
*/
|
|
17
|
+
export function ServerSkillsView({ serverId }: ServerSkillsViewProps) {
|
|
18
|
+
const state = useStore()
|
|
19
|
+
const dispatch = useDispatch()
|
|
20
|
+
const { servers, skills } = useDb()
|
|
21
|
+
|
|
22
|
+
const server = servers.get(serverId)
|
|
23
|
+
const [skillList, setSkillList] = useState<RemoteSkill[]>([])
|
|
24
|
+
const [selectedIndex, setSelectedIndex] = useState(0)
|
|
25
|
+
|
|
26
|
+
useEffect(() => {
|
|
27
|
+
setSkillList(skills.listByServer(serverId))
|
|
28
|
+
}, [serverId])
|
|
29
|
+
|
|
30
|
+
const selectedSkill = skillList[selectedIndex] ?? null
|
|
31
|
+
|
|
32
|
+
useKeyboard((key) => {
|
|
33
|
+
if (state.activeView !== "server-skills") return
|
|
34
|
+
if (state.showHelp) return
|
|
35
|
+
|
|
36
|
+
// j/k or arrow keys
|
|
37
|
+
if (key.name === "up" || (key.name === "k" && !key.ctrl)) {
|
|
38
|
+
setSelectedIndex((i) => Math.max(0, i - 1))
|
|
39
|
+
}
|
|
40
|
+
if (key.name === "down" || (key.name === "j" && !key.ctrl)) {
|
|
41
|
+
setSelectedIndex((i) => Math.min(skillList.length - 1, i + 1))
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// g = first, G = last
|
|
45
|
+
if (key.name === "g" && !key.shift) {
|
|
46
|
+
setSelectedIndex(0)
|
|
47
|
+
}
|
|
48
|
+
if (key.name === "g" && key.shift) {
|
|
49
|
+
setSelectedIndex(Math.max(0, skillList.length - 1))
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// i = install locally (placeholder)
|
|
53
|
+
if (key.name === "i" && selectedSkill) {
|
|
54
|
+
dispatch({
|
|
55
|
+
type: "SHOW_NOTIFICATION",
|
|
56
|
+
notification: {
|
|
57
|
+
type: "info",
|
|
58
|
+
message: `Install from remote is not yet implemented. Skill: ${selectedSkill.name}`,
|
|
59
|
+
},
|
|
60
|
+
})
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Esc = go back (handled by layout)
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
if (!server) {
|
|
68
|
+
return (
|
|
69
|
+
<box style={{ padding: 1 }}>
|
|
70
|
+
<text fg={colors.error}>Server not found</text>
|
|
71
|
+
</box>
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return (
|
|
76
|
+
<box style={{ flexDirection: "column", width: "100%", flexGrow: 1 }}>
|
|
77
|
+
{/* Header bar */}
|
|
78
|
+
<box
|
|
79
|
+
style={{
|
|
80
|
+
height: 1,
|
|
81
|
+
width: "100%",
|
|
82
|
+
paddingLeft: 1,
|
|
83
|
+
backgroundColor: colors.bgAlt,
|
|
84
|
+
flexDirection: "row",
|
|
85
|
+
}}
|
|
86
|
+
>
|
|
87
|
+
<text fg={colors.primary}>{server.label}</text>
|
|
88
|
+
<text fg={colors.textDim}>
|
|
89
|
+
{" "}{server.username}@{server.host}{" "}
|
|
90
|
+
{skillList.length} skill{skillList.length !== 1 ? "s" : ""}
|
|
91
|
+
</text>
|
|
92
|
+
</box>
|
|
93
|
+
|
|
94
|
+
{/* Two-column content */}
|
|
95
|
+
<box style={{ flexDirection: "row", flexGrow: 1, width: "100%" }}>
|
|
96
|
+
{/* LEFT: Skill list */}
|
|
97
|
+
<box
|
|
98
|
+
style={{
|
|
99
|
+
width: "40%",
|
|
100
|
+
borderRight: true,
|
|
101
|
+
borderColor: colors.border,
|
|
102
|
+
flexDirection: "column",
|
|
103
|
+
}}
|
|
104
|
+
>
|
|
105
|
+
<box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
|
|
106
|
+
<text fg={colors.textDim}>REMOTE SKILLS</text>
|
|
107
|
+
</box>
|
|
108
|
+
|
|
109
|
+
{skillList.length === 0 ? (
|
|
110
|
+
<box style={{ padding: 1 }}>
|
|
111
|
+
<text fg={colors.textDim}>
|
|
112
|
+
No cached skills. Go back and sync the server (S).
|
|
113
|
+
</text>
|
|
114
|
+
</box>
|
|
115
|
+
) : (
|
|
116
|
+
<scrollbox
|
|
117
|
+
focused={state.activeView === "server-skills" && !state.showHelp}
|
|
118
|
+
style={{
|
|
119
|
+
width: "100%",
|
|
120
|
+
flexGrow: 1,
|
|
121
|
+
rootOptions: { backgroundColor: colors.bg },
|
|
122
|
+
viewportOptions: { backgroundColor: colors.bg },
|
|
123
|
+
contentOptions: { backgroundColor: colors.bg },
|
|
124
|
+
scrollbarOptions: {
|
|
125
|
+
trackOptions: {
|
|
126
|
+
foregroundColor: colors.primary,
|
|
127
|
+
backgroundColor: colors.border,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
}}
|
|
131
|
+
>
|
|
132
|
+
{skillList.map((skill, i) => (
|
|
133
|
+
<box
|
|
134
|
+
key={skill.id}
|
|
135
|
+
style={{
|
|
136
|
+
width: "100%",
|
|
137
|
+
paddingLeft: 1,
|
|
138
|
+
paddingRight: 1,
|
|
139
|
+
flexDirection: "row",
|
|
140
|
+
backgroundColor: i === selectedIndex ? colors.bgAlt : "transparent",
|
|
141
|
+
}}
|
|
142
|
+
>
|
|
143
|
+
<text fg={i === selectedIndex ? colors.primary : colors.text}>
|
|
144
|
+
{skill.name}
|
|
145
|
+
</text>
|
|
146
|
+
</box>
|
|
147
|
+
))}
|
|
148
|
+
</scrollbox>
|
|
149
|
+
)}
|
|
150
|
+
|
|
151
|
+
{/* Bottom hints */}
|
|
152
|
+
<box style={{ height: 1, paddingLeft: 1, backgroundColor: colors.bgAlt }}>
|
|
153
|
+
<text fg={colors.textDim}>i=install locally Esc=back</text>
|
|
154
|
+
</box>
|
|
155
|
+
</box>
|
|
156
|
+
|
|
157
|
+
{/* RIGHT: Skill detail */}
|
|
158
|
+
<box style={{ flexGrow: 1, flexDirection: "column" }}>
|
|
159
|
+
{selectedSkill ? (
|
|
160
|
+
<RemoteSkillDetail skill={selectedSkill} />
|
|
161
|
+
) : (
|
|
162
|
+
<box style={{ padding: 1 }}>
|
|
163
|
+
<text fg={colors.textDim}>Select a skill to view details</text>
|
|
164
|
+
</box>
|
|
165
|
+
)}
|
|
166
|
+
</box>
|
|
167
|
+
</box>
|
|
168
|
+
</box>
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ---------- Remote Skill Detail ----------
|
|
173
|
+
|
|
174
|
+
function stripFrontmatter(content: string): string {
|
|
175
|
+
const lines = content.split("\n")
|
|
176
|
+
if (lines[0]?.trim() !== "---") return content
|
|
177
|
+
let endIndex = -1
|
|
178
|
+
for (let i = 1; i < lines.length; i++) {
|
|
179
|
+
if (lines[i].trim() === "---") {
|
|
180
|
+
endIndex = i
|
|
181
|
+
break
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
if (endIndex === -1) return content
|
|
185
|
+
return lines.slice(endIndex + 1).join("\n").trimStart()
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
interface RemoteSkillDetailProps {
|
|
189
|
+
skill: RemoteSkill
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function RemoteSkillDetail({ skill }: RemoteSkillDetailProps) {
|
|
193
|
+
const content = skill.content ? stripFrontmatter(skill.content) : ""
|
|
194
|
+
|
|
195
|
+
return (
|
|
196
|
+
<scrollbox
|
|
197
|
+
focused={false}
|
|
198
|
+
style={{
|
|
199
|
+
width: "100%",
|
|
200
|
+
flexGrow: 1,
|
|
201
|
+
rootOptions: { backgroundColor: colors.bg },
|
|
202
|
+
viewportOptions: { backgroundColor: colors.bg },
|
|
203
|
+
contentOptions: { backgroundColor: colors.bg },
|
|
204
|
+
scrollbarOptions: {
|
|
205
|
+
trackOptions: {
|
|
206
|
+
foregroundColor: colors.primary,
|
|
207
|
+
backgroundColor: colors.border,
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
<box style={{ paddingLeft: 1, paddingRight: 1, flexDirection: "column" }}>
|
|
213
|
+
{/* Name */}
|
|
214
|
+
<text fg={colors.primary}>
|
|
215
|
+
<strong>{skill.name}</strong>
|
|
216
|
+
</text>
|
|
217
|
+
|
|
218
|
+
{/* Description */}
|
|
219
|
+
{skill.description ? (
|
|
220
|
+
<text fg={colors.text}>{skill.description}</text>
|
|
221
|
+
) : null}
|
|
222
|
+
<text>{" "}</text>
|
|
223
|
+
|
|
224
|
+
{/* Remote path */}
|
|
225
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
226
|
+
<text fg={colors.textDim}>Remote path: </text>
|
|
227
|
+
<text fg={colors.secondary}>{skill.remotePath}</text>
|
|
228
|
+
</box>
|
|
229
|
+
|
|
230
|
+
{/* Synced at */}
|
|
231
|
+
<box style={{ flexDirection: "row", height: 1 }}>
|
|
232
|
+
<text fg={colors.textDim}>Last synced: </text>
|
|
233
|
+
<text fg={colors.secondary}>{skill.syncedAt}</text>
|
|
234
|
+
</box>
|
|
235
|
+
|
|
236
|
+
<text>{" "}</text>
|
|
237
|
+
<text fg={colors.textDim}>i=install locally Esc=back to server list</text>
|
|
238
|
+
<text fg={colors.border}>---</text>
|
|
239
|
+
|
|
240
|
+
{/* Skill content */}
|
|
241
|
+
{content ? (
|
|
242
|
+
content.split("\n").map((line, i) => {
|
|
243
|
+
if (line.startsWith("### ")) {
|
|
244
|
+
return <text key={i} fg={colors.primary}>{line}</text>
|
|
245
|
+
}
|
|
246
|
+
if (line.startsWith("## ")) {
|
|
247
|
+
return <text key={i} fg={colors.primary}><strong>{line}</strong></text>
|
|
248
|
+
}
|
|
249
|
+
if (line.startsWith("# ")) {
|
|
250
|
+
return <text key={i} fg={colors.primary}><strong>{line}</strong></text>
|
|
251
|
+
}
|
|
252
|
+
if (line.startsWith("```")) {
|
|
253
|
+
return <text key={i} fg={colors.textDim}>{line}</text>
|
|
254
|
+
}
|
|
255
|
+
if (line.trimStart().startsWith("- ") || line.trimStart().startsWith("* ")) {
|
|
256
|
+
return <text key={i} fg={colors.text}>{line}</text>
|
|
257
|
+
}
|
|
258
|
+
if (!line.trim()) {
|
|
259
|
+
return <text key={i}>{" "}</text>
|
|
260
|
+
}
|
|
261
|
+
return <text key={i} fg={colors.text}>{line}</text>
|
|
262
|
+
})
|
|
263
|
+
) : (
|
|
264
|
+
<text fg={colors.textDim}>(No skill content cached)</text>
|
|
265
|
+
)}
|
|
266
|
+
</box>
|
|
267
|
+
</scrollbox>
|
|
268
|
+
)
|
|
269
|
+
}
|