@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,28 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
const { execFileSync } = require("child_process");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const os = require("os");
|
|
5
|
+
|
|
6
|
+
const platform = os.platform();
|
|
7
|
+
const arch = os.arch();
|
|
8
|
+
|
|
9
|
+
// Map to npm package names
|
|
10
|
+
const platformMap = {
|
|
11
|
+
darwin: { arm64: "tui-darwin-arm64", x64: "tui-darwin-x64" },
|
|
12
|
+
linux: { arm64: "tui-linux-arm64", x64: "tui-linux-x64" },
|
|
13
|
+
win32: { x64: "tui-win32-x64" },
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const pkg = platformMap[platform]?.[arch];
|
|
17
|
+
if (!pkg) {
|
|
18
|
+
console.error(`Unsupported platform: ${platform}-${arch}`);
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
try {
|
|
23
|
+
const binPath = require.resolve(`@skillsgate/${pkg}/skillsgate-tui`);
|
|
24
|
+
execFileSync(binPath, process.argv.slice(2), { stdio: "inherit" });
|
|
25
|
+
} catch {
|
|
26
|
+
console.error("Platform binary not found. Try reinstalling: npm install -g @skillsgate/tui");
|
|
27
|
+
process.exit(1);
|
|
28
|
+
}
|
package/bunfig.toml
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@skillsgate/tui",
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"bin": {
|
|
6
|
+
"skillsgate-tui": "bin/skillsgate-tui"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"dev": "bun --watch run src/index.tsx",
|
|
10
|
+
"start": "bun run src/index.tsx"
|
|
11
|
+
},
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"@opentui/core": "0.1.90",
|
|
14
|
+
"@opentui/react": "0.1.90",
|
|
15
|
+
"gray-matter": "^4.0.3"
|
|
16
|
+
},
|
|
17
|
+
"peerDependencies": {
|
|
18
|
+
"react": ">=19.0.0"
|
|
19
|
+
},
|
|
20
|
+
"repository": {
|
|
21
|
+
"type": "git",
|
|
22
|
+
"url": "https://github.com/skillsgate/skillsgate"
|
|
23
|
+
}
|
|
24
|
+
}
|
package/src/app.tsx
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { Database } from "bun:sqlite"
|
|
2
|
+
import { StoreProvider } from "./store/context.js"
|
|
3
|
+
import { DbProvider } from "./db/context.js"
|
|
4
|
+
import { Layout } from "./components/layout.js"
|
|
5
|
+
|
|
6
|
+
interface AppProps {
|
|
7
|
+
db: Database
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function App({ db }: AppProps) {
|
|
11
|
+
return (
|
|
12
|
+
<DbProvider db={db}>
|
|
13
|
+
<StoreProvider>
|
|
14
|
+
<Layout />
|
|
15
|
+
</StoreProvider>
|
|
16
|
+
</DbProvider>
|
|
17
|
+
)
|
|
18
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { useState } 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, agentBadges } from "../utils/colors.js"
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Vertical sidebar showing the Library section (All Skills, Favorites)
|
|
9
|
+
* and the Tools section (detected agents with skill counts).
|
|
10
|
+
* Rendered as the left panel in the three-panel home layout.
|
|
11
|
+
*/
|
|
12
|
+
export function AgentFilter() {
|
|
13
|
+
const state = useStore()
|
|
14
|
+
const dispatch = useDispatch()
|
|
15
|
+
const { servers } = useDb()
|
|
16
|
+
|
|
17
|
+
const allCount = state.installedSkills.length
|
|
18
|
+
|
|
19
|
+
// Remote servers with skill counts
|
|
20
|
+
const serverList = servers.list()
|
|
21
|
+
const serverEntries = serverList.map((srv) => ({
|
|
22
|
+
id: srv.id,
|
|
23
|
+
label: srv.label,
|
|
24
|
+
count: servers.skillCount(srv.id),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
// Build the list of filter options
|
|
28
|
+
const agentOptions = state.detectedAgents.map((a) => ({
|
|
29
|
+
name: a.displayName,
|
|
30
|
+
value: a.name,
|
|
31
|
+
count: a.skillCount,
|
|
32
|
+
badge: agentBadges[a.name],
|
|
33
|
+
}))
|
|
34
|
+
|
|
35
|
+
// Navigate agent filters with keyboard when agents pane is focused
|
|
36
|
+
useKeyboard((key) => {
|
|
37
|
+
if (state.activeView !== "home") return
|
|
38
|
+
if (state.focusedPane !== "agents") return
|
|
39
|
+
if (state.showHelp) return
|
|
40
|
+
|
|
41
|
+
const allOptions = ["all", ...agentOptions.map((o) => o.value)]
|
|
42
|
+
const currentIdx = allOptions.indexOf(state.selectedAgentFilter)
|
|
43
|
+
|
|
44
|
+
if (key.name === "up" || (key.name === "k" && !key.ctrl)) {
|
|
45
|
+
const prev = Math.max(0, currentIdx - 1)
|
|
46
|
+
dispatch({ type: "SET_AGENT_FILTER", filter: allOptions[prev] })
|
|
47
|
+
}
|
|
48
|
+
if (key.name === "down" || (key.name === "j" && !key.ctrl)) {
|
|
49
|
+
const next = Math.min(allOptions.length - 1, currentIdx + 1)
|
|
50
|
+
dispatch({ type: "SET_AGENT_FILTER", filter: allOptions[next] })
|
|
51
|
+
}
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const isFocused = state.focusedPane === "agents"
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
<box
|
|
58
|
+
style={{
|
|
59
|
+
flexDirection: "column",
|
|
60
|
+
width: 22,
|
|
61
|
+
borderRight: true,
|
|
62
|
+
borderColor: isFocused ? colors.primary : colors.border,
|
|
63
|
+
backgroundColor: colors.bg,
|
|
64
|
+
paddingTop: 0,
|
|
65
|
+
}}
|
|
66
|
+
>
|
|
67
|
+
{/* Library section header */}
|
|
68
|
+
<box style={{ paddingLeft: 1, height: 1, backgroundColor: colors.bgAlt }}>
|
|
69
|
+
<text fg={colors.textDim}>LIBRARY</text>
|
|
70
|
+
</box>
|
|
71
|
+
|
|
72
|
+
{/* All Skills */}
|
|
73
|
+
<box
|
|
74
|
+
style={{
|
|
75
|
+
paddingLeft: 1,
|
|
76
|
+
paddingRight: 1,
|
|
77
|
+
height: 1,
|
|
78
|
+
backgroundColor: state.selectedAgentFilter === "all" ? colors.bgAlt : "transparent",
|
|
79
|
+
}}
|
|
80
|
+
>
|
|
81
|
+
<text fg={state.selectedAgentFilter === "all" ? colors.primary : colors.text}>
|
|
82
|
+
All Skills
|
|
83
|
+
</text>
|
|
84
|
+
<text fg={colors.textDim}> ({allCount})</text>
|
|
85
|
+
</box>
|
|
86
|
+
|
|
87
|
+
{/* Spacer */}
|
|
88
|
+
<box style={{ height: 1 }}>
|
|
89
|
+
<text>{" "}</text>
|
|
90
|
+
</box>
|
|
91
|
+
|
|
92
|
+
{/* Tools section header */}
|
|
93
|
+
<box style={{ paddingLeft: 1, height: 1, backgroundColor: colors.bgAlt }}>
|
|
94
|
+
<text fg={colors.textDim}>TOOLS</text>
|
|
95
|
+
</box>
|
|
96
|
+
|
|
97
|
+
{/* Agent entries */}
|
|
98
|
+
{agentOptions.length === 0 ? (
|
|
99
|
+
<box style={{ paddingLeft: 1, height: 1 }}>
|
|
100
|
+
<text fg={colors.textDim}>(none)</text>
|
|
101
|
+
</box>
|
|
102
|
+
) : (
|
|
103
|
+
agentOptions.map((opt) => {
|
|
104
|
+
const isActive = state.selectedAgentFilter === opt.value
|
|
105
|
+
return (
|
|
106
|
+
<box
|
|
107
|
+
key={opt.value}
|
|
108
|
+
style={{
|
|
109
|
+
paddingLeft: 1,
|
|
110
|
+
paddingRight: 1,
|
|
111
|
+
height: 1,
|
|
112
|
+
flexDirection: "row",
|
|
113
|
+
backgroundColor: isActive ? colors.bgAlt : "transparent",
|
|
114
|
+
}}
|
|
115
|
+
>
|
|
116
|
+
<text fg={opt.badge?.color ?? colors.agent}>
|
|
117
|
+
{opt.badge?.label ?? opt.name.slice(0, 2)}
|
|
118
|
+
</text>
|
|
119
|
+
<text fg={isActive ? colors.primary : colors.text}>
|
|
120
|
+
{" "}{opt.name}
|
|
121
|
+
</text>
|
|
122
|
+
<text fg={colors.textDim}> {opt.count}</text>
|
|
123
|
+
</box>
|
|
124
|
+
)
|
|
125
|
+
})
|
|
126
|
+
)}
|
|
127
|
+
|
|
128
|
+
{/* Spacer */}
|
|
129
|
+
<box style={{ height: 1 }}>
|
|
130
|
+
<text>{" "}</text>
|
|
131
|
+
</box>
|
|
132
|
+
|
|
133
|
+
{/* Servers section header */}
|
|
134
|
+
<box style={{ paddingLeft: 1, height: 1, backgroundColor: colors.bgAlt }}>
|
|
135
|
+
<text fg={colors.textDim}>SERVERS</text>
|
|
136
|
+
</box>
|
|
137
|
+
|
|
138
|
+
{/* Server entries */}
|
|
139
|
+
{serverEntries.length === 0 ? (
|
|
140
|
+
<box style={{ paddingLeft: 1, height: 1 }}>
|
|
141
|
+
<text fg={colors.textDim}>(none)</text>
|
|
142
|
+
</box>
|
|
143
|
+
) : (
|
|
144
|
+
serverEntries.map((srv) => (
|
|
145
|
+
<box
|
|
146
|
+
key={srv.id}
|
|
147
|
+
style={{
|
|
148
|
+
paddingLeft: 1,
|
|
149
|
+
paddingRight: 1,
|
|
150
|
+
height: 1,
|
|
151
|
+
flexDirection: "row",
|
|
152
|
+
}}
|
|
153
|
+
>
|
|
154
|
+
<text fg={colors.secondary}>S </text>
|
|
155
|
+
<text fg={colors.text}>{srv.label}</text>
|
|
156
|
+
<text fg={colors.textDim}> {srv.count}</text>
|
|
157
|
+
</box>
|
|
158
|
+
))
|
|
159
|
+
)}
|
|
160
|
+
</box>
|
|
161
|
+
)
|
|
162
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { useKeyboard } from "@opentui/react"
|
|
2
|
+
import { colors } from "../utils/colors.js"
|
|
3
|
+
|
|
4
|
+
interface ConfirmDialogProps {
|
|
5
|
+
message: string
|
|
6
|
+
onConfirm: () => void
|
|
7
|
+
onCancel: () => void
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Overlay confirmation dialog that captures y/n keypress.
|
|
12
|
+
* Renders as a centered box with the message and (y/n) hint.
|
|
13
|
+
*/
|
|
14
|
+
export function ConfirmDialog({ message, onConfirm, onCancel }: ConfirmDialogProps) {
|
|
15
|
+
useKeyboard((key) => {
|
|
16
|
+
if (key.name === "y") {
|
|
17
|
+
onConfirm()
|
|
18
|
+
} else if (key.name === "n" || key.name === "escape") {
|
|
19
|
+
onCancel()
|
|
20
|
+
}
|
|
21
|
+
})
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<box
|
|
25
|
+
style={{
|
|
26
|
+
width: "100%",
|
|
27
|
+
height: "100%",
|
|
28
|
+
justifyContent: "center",
|
|
29
|
+
alignItems: "center",
|
|
30
|
+
backgroundColor: colors.bg,
|
|
31
|
+
}}
|
|
32
|
+
>
|
|
33
|
+
<box
|
|
34
|
+
style={{
|
|
35
|
+
width: 60,
|
|
36
|
+
border: true,
|
|
37
|
+
borderColor: colors.primary,
|
|
38
|
+
backgroundColor: "#1a1a2e",
|
|
39
|
+
paddingLeft: 2,
|
|
40
|
+
paddingRight: 2,
|
|
41
|
+
paddingTop: 1,
|
|
42
|
+
paddingBottom: 1,
|
|
43
|
+
flexDirection: "column",
|
|
44
|
+
alignItems: "center",
|
|
45
|
+
}}
|
|
46
|
+
title="Confirm"
|
|
47
|
+
>
|
|
48
|
+
<text fg={colors.text}>{message}</text>
|
|
49
|
+
<text>{" "}</text>
|
|
50
|
+
<text fg={colors.textDim}>
|
|
51
|
+
Press <span fg={colors.success}>y</span> to confirm, <span fg={colors.error}>n</span> to cancel
|
|
52
|
+
</text>
|
|
53
|
+
</box>
|
|
54
|
+
</box>
|
|
55
|
+
)
|
|
56
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import { colors } from "../utils/colors.js"
|
|
2
|
+
|
|
3
|
+
interface ShortcutEntry {
|
|
4
|
+
key: string
|
|
5
|
+
description: string
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const SHORTCUTS_LEFT: ShortcutEntry[] = [
|
|
9
|
+
{ key: "j/k", description: "Navigate list up/down" },
|
|
10
|
+
{ key: "g", description: "Jump to first item" },
|
|
11
|
+
{ key: "G", description: "Jump to last item" },
|
|
12
|
+
{ key: "v", description: "View skill detail" },
|
|
13
|
+
{ key: "/", description: "Focus search input" },
|
|
14
|
+
{ key: "Tab", description: "Cycle focus: agents > search > list" },
|
|
15
|
+
{ key: "Esc", description: "Clear search / go back" },
|
|
16
|
+
]
|
|
17
|
+
|
|
18
|
+
const SHORTCUTS_RIGHT: ShortcutEntry[] = [
|
|
19
|
+
{ key: "1/2/3/4", description: "Switch tabs" },
|
|
20
|
+
{ key: "s", description: "Open settings" },
|
|
21
|
+
{ key: "r", description: "Refresh installed skills" },
|
|
22
|
+
{ key: "l", description: "Login / auth status" },
|
|
23
|
+
{ key: "i", description: "Install selected skill" },
|
|
24
|
+
{ key: "d", description: "Remove selected skill" },
|
|
25
|
+
{ key: "x", description: "Unfavorite (favorites view)" },
|
|
26
|
+
{ key: "m", description: "Toggle keyword/AI search" },
|
|
27
|
+
{ key: "?", description: "Toggle this help" },
|
|
28
|
+
{ key: "Ctrl+Q", description: "Quit" },
|
|
29
|
+
{ key: "", description: "" },
|
|
30
|
+
{ key: "-- Servers View --", description: "" },
|
|
31
|
+
{ key: "S", description: "Sync selected server" },
|
|
32
|
+
{ key: "a", description: "Add new server" },
|
|
33
|
+
{ key: "e", description: "Edit server" },
|
|
34
|
+
{ key: "t", description: "Test connection" },
|
|
35
|
+
{ key: "", description: "" },
|
|
36
|
+
{ key: "-- Detail View --", description: "" },
|
|
37
|
+
{ key: "q/Esc", description: "Go back" },
|
|
38
|
+
{ key: "e", description: "Edit skill in $EDITOR" },
|
|
39
|
+
{ key: "o", description: "Open folder / source URL" },
|
|
40
|
+
{ key: "d", description: "Remove (per-agent if multiple)" },
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
const KEY_COL_WIDTH = 18
|
|
44
|
+
const DESC_COL_WIDTH = 34
|
|
45
|
+
|
|
46
|
+
function formatShortcutLine(entry: ShortcutEntry): string {
|
|
47
|
+
if (!entry.key && !entry.description) return ""
|
|
48
|
+
if (entry.key.startsWith("--")) {
|
|
49
|
+
return entry.key
|
|
50
|
+
}
|
|
51
|
+
const keyPadded = entry.key.padEnd(KEY_COL_WIDTH)
|
|
52
|
+
return `${keyPadded}${entry.description}`
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function HelpOverlay() {
|
|
56
|
+
const maxRows = Math.max(SHORTCUTS_LEFT.length, SHORTCUTS_RIGHT.length)
|
|
57
|
+
const lines: string[] = []
|
|
58
|
+
|
|
59
|
+
// Title
|
|
60
|
+
lines.push("")
|
|
61
|
+
lines.push(" Keyboard Shortcuts")
|
|
62
|
+
lines.push(" " + "-".repeat(KEY_COL_WIDTH + DESC_COL_WIDTH + 4 + KEY_COL_WIDTH + DESC_COL_WIDTH))
|
|
63
|
+
lines.push("")
|
|
64
|
+
|
|
65
|
+
for (let i = 0; i < maxRows; i++) {
|
|
66
|
+
const left = SHORTCUTS_LEFT[i]
|
|
67
|
+
const right = SHORTCUTS_RIGHT[i]
|
|
68
|
+
|
|
69
|
+
const leftStr = left ? formatShortcutLine(left) : ""
|
|
70
|
+
const rightStr = right ? formatShortcutLine(right) : ""
|
|
71
|
+
|
|
72
|
+
const leftPadded = leftStr.padEnd(KEY_COL_WIDTH + DESC_COL_WIDTH + 2)
|
|
73
|
+
lines.push(` ${leftPadded} ${rightStr}`)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
lines.push("")
|
|
77
|
+
lines.push(" Press ? or Esc to close")
|
|
78
|
+
lines.push("")
|
|
79
|
+
|
|
80
|
+
return (
|
|
81
|
+
<box
|
|
82
|
+
style={{
|
|
83
|
+
width: "100%",
|
|
84
|
+
flexGrow: 1,
|
|
85
|
+
backgroundColor: "#1a1a2e",
|
|
86
|
+
border: true,
|
|
87
|
+
borderColor: colors.primary,
|
|
88
|
+
flexDirection: "column",
|
|
89
|
+
paddingLeft: 1,
|
|
90
|
+
paddingRight: 1,
|
|
91
|
+
}}
|
|
92
|
+
title="Help"
|
|
93
|
+
>
|
|
94
|
+
{lines.map((line, i) => (
|
|
95
|
+
<text key={i} fg={line.includes("--") && !line.includes("Keyboard") ? colors.primary : colors.text}>
|
|
96
|
+
{line}
|
|
97
|
+
</text>
|
|
98
|
+
))}
|
|
99
|
+
</box>
|
|
100
|
+
)
|
|
101
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { useState } from "react"
|
|
2
|
+
import { useKeyboard, useTerminalDimensions } from "@opentui/react"
|
|
3
|
+
import { useStore, useDispatch } from "../store/context.js"
|
|
4
|
+
import { useDb } from "../db/context.js"
|
|
5
|
+
import { useDetectedAgents } from "../data/use-agents.js"
|
|
6
|
+
import { useInstalledSkills } from "../data/use-installed-skills.js"
|
|
7
|
+
import { useAuth } from "../data/use-auth.js"
|
|
8
|
+
import { StatusBar } from "./status-bar.js"
|
|
9
|
+
import { HelpOverlay } from "./help-overlay.js"
|
|
10
|
+
import { HomeView } from "../views/home.js"
|
|
11
|
+
import { SkillDetailView } from "../views/skill-detail.js"
|
|
12
|
+
import { DiscoverView } from "../views/discover.js"
|
|
13
|
+
import { FavoritesView } from "../views/favorites.js"
|
|
14
|
+
import { ServersView } from "../views/servers.js"
|
|
15
|
+
import { AddServerView } from "../views/add-server.js"
|
|
16
|
+
import { ServerSkillsView } from "../views/server-skills.js"
|
|
17
|
+
import { SettingsView } from "../views/settings.js"
|
|
18
|
+
import { LoginView } from "../views/login.js"
|
|
19
|
+
import { colors } from "../utils/colors.js"
|
|
20
|
+
import type { ViewName } from "../store/types.js"
|
|
21
|
+
|
|
22
|
+
function getTabOptions(favCount: number, serverCount: number) {
|
|
23
|
+
return [
|
|
24
|
+
{ name: "Installed", description: "Locally installed skills", value: "home" },
|
|
25
|
+
{ name: "Discover", description: "Search the registry", value: "discover" },
|
|
26
|
+
{
|
|
27
|
+
name: favCount > 0 ? `Favorites (${favCount})` : "Favorites",
|
|
28
|
+
description: "Your starred skills",
|
|
29
|
+
value: "favorites",
|
|
30
|
+
},
|
|
31
|
+
{
|
|
32
|
+
name: serverCount > 0 ? `Servers (${serverCount})` : "Servers",
|
|
33
|
+
description: "Remote SSH servers",
|
|
34
|
+
value: "servers",
|
|
35
|
+
},
|
|
36
|
+
]
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function Layout() {
|
|
40
|
+
const state = useStore()
|
|
41
|
+
const dispatch = useDispatch()
|
|
42
|
+
const { width, height } = useTerminalDimensions()
|
|
43
|
+
const { servers } = useDb()
|
|
44
|
+
const [serverCount, setServerCount] = useState(() => servers.list().length)
|
|
45
|
+
|
|
46
|
+
// Load auth, agent + skill data on mount
|
|
47
|
+
useAuth()
|
|
48
|
+
useDetectedAgents()
|
|
49
|
+
useInstalledSkills()
|
|
50
|
+
|
|
51
|
+
// Global keyboard shortcuts
|
|
52
|
+
useKeyboard((key) => {
|
|
53
|
+
// Ctrl+Q always works -- clean exit
|
|
54
|
+
if (key.name === "q" && key.ctrl) {
|
|
55
|
+
const exit = (globalThis as any).__skillsgateTuiCleanExit
|
|
56
|
+
if (exit) exit()
|
|
57
|
+
else process.exit(0)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// When search input is focused, only handle Escape, Tab, and Ctrl shortcuts
|
|
61
|
+
// All other keys pass through to the input component
|
|
62
|
+
if (state.focusedPane === "search") {
|
|
63
|
+
if (key.name === "escape") {
|
|
64
|
+
dispatch({ type: "SET_FOCUSED_PANE", pane: "list" })
|
|
65
|
+
return
|
|
66
|
+
}
|
|
67
|
+
if (key.name === "tab" && !key.shift) {
|
|
68
|
+
dispatch({ type: "CYCLE_FOCUS" })
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
return
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// When on login view, only handle Escape -- let input keys pass through
|
|
75
|
+
if (state.activeView === "login") {
|
|
76
|
+
if (key.name === "escape") {
|
|
77
|
+
dispatch({ type: "GO_BACK" })
|
|
78
|
+
}
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Help overlay toggle
|
|
83
|
+
if (key.name === "?" || (key.shift && key.name === "/")) {
|
|
84
|
+
dispatch({ type: "TOGGLE_HELP" })
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Dismiss help with Esc
|
|
89
|
+
if (state.showHelp && key.name === "escape") {
|
|
90
|
+
dispatch({ type: "TOGGLE_HELP" })
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// When help is shown, block other shortcuts
|
|
95
|
+
if (state.showHelp) return
|
|
96
|
+
|
|
97
|
+
// Tab switching (only when not in detail/form views)
|
|
98
|
+
const inFormView = state.activeView === "detail" || state.activeView === "add-server"
|
|
99
|
+
|| state.activeView === "edit-server" || state.activeView === "settings"
|
|
100
|
+
|| state.activeView === "server-skills" || state.activeView === "login"
|
|
101
|
+
if (!inFormView) {
|
|
102
|
+
if (key.name === "1") dispatch({ type: "NAVIGATE", view: "home" })
|
|
103
|
+
if (key.name === "2") dispatch({ type: "NAVIGATE", view: "discover" })
|
|
104
|
+
if (key.name === "3") dispatch({ type: "NAVIGATE", view: "favorites" })
|
|
105
|
+
if (key.name === "4") {
|
|
106
|
+
setServerCount(servers.list().length)
|
|
107
|
+
dispatch({ type: "NAVIGATE", view: "servers" })
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// "s" to open settings (only from home/favorites views when not in search)
|
|
112
|
+
if (key.name === "s" && state.focusedPane !== "search"
|
|
113
|
+
&& state.activeView !== "discover" && state.activeView !== "detail"
|
|
114
|
+
&& !inFormView) {
|
|
115
|
+
dispatch({ type: "NAVIGATE", view: "settings" })
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Tab to cycle focus (only on home/discover views)
|
|
120
|
+
if (key.name === "tab" && !key.shift && (state.activeView === "home" || state.activeView === "discover")) {
|
|
121
|
+
dispatch({ type: "CYCLE_FOCUS" })
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// "/" to focus search from anywhere
|
|
126
|
+
if (key.name === "/" && state.activeView !== "detail") {
|
|
127
|
+
dispatch({ type: "SET_FOCUSED_PANE", pane: "search" })
|
|
128
|
+
return
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Esc: go back from sub-views, clear search, etc.
|
|
132
|
+
if (key.name === "escape") {
|
|
133
|
+
if (state.activeView === "detail" || state.activeView === "add-server"
|
|
134
|
+
|| state.activeView === "edit-server" || state.activeView === "settings"
|
|
135
|
+
|| state.activeView === "server-skills") {
|
|
136
|
+
dispatch({ type: "GO_BACK" })
|
|
137
|
+
return
|
|
138
|
+
}
|
|
139
|
+
if (state.installedFilter) {
|
|
140
|
+
dispatch({ type: "SET_INSTALLED_FILTER", filter: "" })
|
|
141
|
+
}
|
|
142
|
+
if (state.focusedPane === "search") {
|
|
143
|
+
dispatch({ type: "SET_FOCUSED_PANE", pane: "list" })
|
|
144
|
+
}
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// "l" to navigate to login view (always -- allows re-login if token expired)
|
|
149
|
+
if (key.name === "l" && state.focusedPane !== "search" && state.activeView !== "detail" && state.activeView !== "login") {
|
|
150
|
+
dispatch({ type: "NAVIGATE", view: "login" })
|
|
151
|
+
return
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// "r" to refresh installed skills (when not typing in search, not on login view)
|
|
155
|
+
if (key.name === "r" && state.focusedPane !== "search" && state.activeView !== "detail" && state.activeView !== "login") {
|
|
156
|
+
dispatch({ type: "REFRESH_SKILLS" })
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
const TAB_OPTIONS = getTabOptions(state.favorites.length, serverCount)
|
|
162
|
+
|
|
163
|
+
const activeTabIndex = TAB_OPTIONS.findIndex(
|
|
164
|
+
(t) => t.value === state.activeView
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
return (
|
|
168
|
+
<box
|
|
169
|
+
style={{
|
|
170
|
+
width: "100%",
|
|
171
|
+
height: "100%",
|
|
172
|
+
flexDirection: "column",
|
|
173
|
+
backgroundColor: colors.bg,
|
|
174
|
+
}}
|
|
175
|
+
>
|
|
176
|
+
{/* Header */}
|
|
177
|
+
<box
|
|
178
|
+
style={{
|
|
179
|
+
height: 1,
|
|
180
|
+
width: "100%",
|
|
181
|
+
backgroundColor: colors.header,
|
|
182
|
+
flexDirection: "row",
|
|
183
|
+
paddingLeft: 1,
|
|
184
|
+
paddingRight: 1,
|
|
185
|
+
justifyContent: "space-between",
|
|
186
|
+
}}
|
|
187
|
+
>
|
|
188
|
+
<text fg={colors.primary}>
|
|
189
|
+
<strong>SkillsGate TUI</strong> <span fg={colors.textDim}>v0.1.0</span>
|
|
190
|
+
</text>
|
|
191
|
+
</box>
|
|
192
|
+
|
|
193
|
+
{/* Tab navigation */}
|
|
194
|
+
<tab-select
|
|
195
|
+
options={TAB_OPTIONS}
|
|
196
|
+
focused={state.activeView !== "detail" && !state.showHelp}
|
|
197
|
+
selectedIndex={activeTabIndex >= 0 ? activeTabIndex : 0}
|
|
198
|
+
selectedBackgroundColor={colors.tabActive}
|
|
199
|
+
selectedTextColor={colors.tabText}
|
|
200
|
+
textColor={colors.textDim}
|
|
201
|
+
backgroundColor={colors.bg}
|
|
202
|
+
showDescription={false}
|
|
203
|
+
showUnderline={true}
|
|
204
|
+
wrapSelection={true}
|
|
205
|
+
onChange={(index: number) => {
|
|
206
|
+
const view = TAB_OPTIONS[index]?.value as ViewName | undefined
|
|
207
|
+
if (view) dispatch({ type: "NAVIGATE", view })
|
|
208
|
+
}}
|
|
209
|
+
/>
|
|
210
|
+
|
|
211
|
+
{/* Content area */}
|
|
212
|
+
<box style={{ flexGrow: 1, width: "100%" }}>
|
|
213
|
+
{state.showHelp ? (
|
|
214
|
+
<HelpOverlay />
|
|
215
|
+
) : (
|
|
216
|
+
<>
|
|
217
|
+
{state.activeView === "home" && <HomeView />}
|
|
218
|
+
{state.activeView === "discover" && <DiscoverView />}
|
|
219
|
+
{state.activeView === "favorites" && <FavoritesView />}
|
|
220
|
+
{state.activeView === "servers" && <ServersView onServerCountChange={setServerCount} />}
|
|
221
|
+
{(state.activeView === "add-server" || state.activeView === "edit-server") && (
|
|
222
|
+
<AddServerView
|
|
223
|
+
editServerId={state.activeView === "edit-server" ? state.selectedServerId : null}
|
|
224
|
+
onServerCountChange={setServerCount}
|
|
225
|
+
/>
|
|
226
|
+
)}
|
|
227
|
+
{state.activeView === "server-skills" && state.selectedServerId && (
|
|
228
|
+
<ServerSkillsView serverId={state.selectedServerId} />
|
|
229
|
+
)}
|
|
230
|
+
{state.activeView === "settings" && <SettingsView />}
|
|
231
|
+
{state.activeView === "login" && <LoginView />}
|
|
232
|
+
{state.activeView === "detail" && state.selectedSkill && (
|
|
233
|
+
<SkillDetailView />
|
|
234
|
+
)}
|
|
235
|
+
</>
|
|
236
|
+
)}
|
|
237
|
+
</box>
|
|
238
|
+
|
|
239
|
+
{/* Notification bar (conditional) */}
|
|
240
|
+
{state.notification && (
|
|
241
|
+
<box
|
|
242
|
+
style={{
|
|
243
|
+
height: 1,
|
|
244
|
+
width: "100%",
|
|
245
|
+
backgroundColor:
|
|
246
|
+
state.notification.type === "error"
|
|
247
|
+
? "#331111"
|
|
248
|
+
: state.notification.type === "success"
|
|
249
|
+
? "#113311"
|
|
250
|
+
: "#111133",
|
|
251
|
+
paddingLeft: 1,
|
|
252
|
+
}}
|
|
253
|
+
>
|
|
254
|
+
<text
|
|
255
|
+
fg={
|
|
256
|
+
state.notification.type === "error"
|
|
257
|
+
? colors.error
|
|
258
|
+
: state.notification.type === "success"
|
|
259
|
+
? colors.success
|
|
260
|
+
: colors.primary
|
|
261
|
+
}
|
|
262
|
+
>
|
|
263
|
+
{state.notification.message}
|
|
264
|
+
</text>
|
|
265
|
+
</box>
|
|
266
|
+
)}
|
|
267
|
+
|
|
268
|
+
{/* Status bar */}
|
|
269
|
+
<StatusBar />
|
|
270
|
+
</box>
|
|
271
|
+
)
|
|
272
|
+
}
|