feat: Add configuration management UI for /agents page
- Add 6-tab horizontal bar: Files, Tools, MCPs, Plugins, Hooks, Skills - Backend: permission parser, config/known-tools/skills/plugins/mcp-json endpoints - Backend: POST endpoints for permissions, hooks, and MCP config - Store: tool entries with 3-state toggle, MCP servers, hooks CRUD, skills/plugins fetch - ToolsManager: search, grouped cards (base/MCP), ask/allow/deny cycle, parameterized rules - McpManager: server cards with enable/disable, add/edit/delete modal - PluginsManager: read-only global plugin cards from ~/.claude/plugins/ - HooksManager: accordion by event type, inline edit with matcher/command/timeout - SkillsManager: two-column layout with SKILL.md preview and references
This commit is contained in:
505
frontend/src/components/agents/HooksManager.vue
Normal file
505
frontend/src/components/agents/HooksManager.vue
Normal file
@@ -0,0 +1,505 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAgentsStore, HOOK_EVENT_TYPES } from '../../stores/agents'
|
||||
import type { HookEntry, HookCommand } from '../../stores/agents'
|
||||
|
||||
const store = useAgentsStore()
|
||||
const editingHook = ref<{ eventType: string; index: number } | null>(null)
|
||||
const editMatcher = ref('')
|
||||
const editCommand = ref('')
|
||||
const editTimeout = ref(5000)
|
||||
|
||||
function hookCount(eventType: string): number {
|
||||
return store.hooksConfig[eventType]?.length || 0
|
||||
}
|
||||
|
||||
function totalHookCount(): number {
|
||||
return Object.values(store.hooksConfig).reduce((sum, arr) => sum + (arr?.length || 0), 0)
|
||||
}
|
||||
|
||||
function startEdit(eventType: string, index: number) {
|
||||
const entry = store.hooksConfig[eventType]?.[index]
|
||||
if (!entry) return
|
||||
editingHook.value = { eventType, index }
|
||||
editMatcher.value = entry.matcher || ''
|
||||
editCommand.value = entry.hooks?.[0]?.command || ''
|
||||
editTimeout.value = entry.hooks?.[0]?.timeout || 5000
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (!editingHook.value) return
|
||||
const { eventType, index } = editingHook.value
|
||||
|
||||
const entry: HookEntry = {
|
||||
matcher: editMatcher.value.trim() || null,
|
||||
hooks: [{
|
||||
type: 'command',
|
||||
command: editCommand.value.trim(),
|
||||
timeout: editTimeout.value
|
||||
}]
|
||||
}
|
||||
|
||||
store.updateHook(eventType, index, entry)
|
||||
editingHook.value = null
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
editingHook.value = null
|
||||
}
|
||||
|
||||
function truncateCommand(cmd: string, max = 60): string {
|
||||
return cmd.length > max ? cmd.slice(0, max) + '...' : cmd
|
||||
}
|
||||
|
||||
const eventTypeLabels: Record<string, string> = {
|
||||
'UserPromptSubmit': 'User Prompt Submit',
|
||||
'PreToolUse': 'Pre Tool Use',
|
||||
'PostToolUse': 'Post Tool Use',
|
||||
'SessionStart': 'Session Start',
|
||||
'Stop': 'Stop',
|
||||
'Notification': 'Notification',
|
||||
'PermissionRequest': 'Permission Request'
|
||||
}
|
||||
|
||||
const eventTypeColors: Record<string, string> = {
|
||||
'UserPromptSubmit': '#3b82f6',
|
||||
'PreToolUse': '#f59e0b',
|
||||
'PostToolUse': '#22c55e',
|
||||
'SessionStart': '#6366f1',
|
||||
'Stop': '#ef4444',
|
||||
'Notification': '#8b5cf6',
|
||||
'PermissionRequest': '#06b6d4'
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="hooks-manager">
|
||||
<!-- Header -->
|
||||
<div class="hooks-header">
|
||||
<div class="hooks-header-left">
|
||||
<h3>Hooks</h3>
|
||||
<span class="hooks-count">{{ totalHookCount() }} hooks</span>
|
||||
<span v-if="store.configFile" class="hooks-path">{{ store.configFile }}</span>
|
||||
</div>
|
||||
<div class="hooks-header-right">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="store.saving"
|
||||
@click="store.saveHooks()"
|
||||
>{{ store.saving ? 'Saving...' : 'Save Hooks' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.hooksLoading" class="hooks-loading">Loading hooks...</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="store.error" class="hooks-error">{{ store.error }}</div>
|
||||
|
||||
<!-- Hook sections by event type -->
|
||||
<div v-if="!store.hooksLoading" class="hooks-scroll">
|
||||
<div v-for="eventType in HOOK_EVENT_TYPES" :key="eventType" class="hook-section">
|
||||
<button
|
||||
class="hook-section-header"
|
||||
@click="store.toggleHookType(eventType)"
|
||||
>
|
||||
<span class="hook-dot" :style="{ background: eventTypeColors[eventType] }"></span>
|
||||
<span class="hook-type-label">{{ eventTypeLabels[eventType] || eventType }}</span>
|
||||
<span
|
||||
v-if="hookCount(eventType)"
|
||||
class="hook-type-count"
|
||||
:style="{ background: eventTypeColors[eventType] + '18', color: eventTypeColors[eventType] }"
|
||||
>{{ hookCount(eventType) }}</span>
|
||||
<svg
|
||||
class="hook-chevron"
|
||||
:class="{ expanded: store.expandedHookTypes.has(eventType) }"
|
||||
xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24"
|
||||
fill="none" stroke="currentColor" stroke-width="2"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9"/>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<div v-if="store.expandedHookTypes.has(eventType)" class="hook-section-body">
|
||||
<!-- Hook entries -->
|
||||
<div
|
||||
v-for="(entry, idx) in (store.hooksConfig[eventType] || [])"
|
||||
:key="idx"
|
||||
class="hook-entry"
|
||||
>
|
||||
<!-- View mode -->
|
||||
<template v-if="!editingHook || editingHook.eventType !== eventType || editingHook.index !== idx">
|
||||
<div class="hook-entry-info">
|
||||
<div v-if="entry.matcher" class="hook-matcher">
|
||||
<span class="entry-label">Matcher</span>
|
||||
<code>{{ entry.matcher }}</code>
|
||||
</div>
|
||||
<div v-for="(hook, hIdx) in entry.hooks" :key="hIdx" class="hook-command-row">
|
||||
<span class="entry-label">Command</span>
|
||||
<code :title="hook.command">{{ truncateCommand(hook.command) }}</code>
|
||||
<span v-if="hook.timeout" class="hook-timeout">{{ hook.timeout }}ms</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hook-entry-actions">
|
||||
<button class="btn-link" @click="startEdit(eventType, idx)">Edit</button>
|
||||
<button class="btn-link danger" @click="store.removeHook(eventType, idx)">Remove</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Edit mode -->
|
||||
<template v-else>
|
||||
<div class="hook-edit-form">
|
||||
<div class="form-row">
|
||||
<label>Matcher</label>
|
||||
<input type="text" v-model="editMatcher" placeholder=".* (regex or empty)" />
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Command</label>
|
||||
<textarea v-model="editCommand" rows="2" placeholder="bash script.sh"></textarea>
|
||||
</div>
|
||||
<div class="form-row">
|
||||
<label>Timeout (ms)</label>
|
||||
<input type="number" v-model.number="editTimeout" />
|
||||
</div>
|
||||
<div class="form-row-actions">
|
||||
<button class="btn btn-secondary btn-xs" @click="cancelEdit">Cancel</button>
|
||||
<button class="btn btn-primary btn-xs" @click="saveEdit">Save</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<!-- Empty state for this type -->
|
||||
<div v-if="!hookCount(eventType)" class="hook-empty">No hooks configured</div>
|
||||
|
||||
<!-- Add button -->
|
||||
<button class="hook-add-btn" @click="store.addHook(eventType)">
|
||||
+ Add hook
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hooks-manager {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hooks-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hooks-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.hooks-header-left h3 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.hooks-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.hooks-path {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.hooks-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hooks-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.hooks-error {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.hooks-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1.25rem;
|
||||
}
|
||||
|
||||
/* Hook sections */
|
||||
.hook-section {
|
||||
margin-bottom: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.hook-section-header {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border: none;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.hook-section-header:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.hook-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hook-type-label {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.hook-type-count {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.4375rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.hook-chevron {
|
||||
color: var(--text-muted);
|
||||
transition: transform 0.2s;
|
||||
flex-shrink: 0;
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
.hook-chevron.expanded {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
.hook-section-body {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
/* Hook entries */
|
||||
.hook-entry {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.hook-entry + .hook-entry {
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.hook-entry-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hook-matcher,
|
||||
.hook-command-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
|
||||
.entry-label {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
min-width: 55px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hook-entry code {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Consolas', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.hook-timeout {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.hook-entry-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
padding-top: 0.125rem;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6366f1;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-link.danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Edit form */
|
||||
.hook-edit-form {
|
||||
width: 100%;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-row label {
|
||||
display: block;
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 0.1875rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.form-row input,
|
||||
.form-row textarea {
|
||||
width: 100%;
|
||||
padding: 0.3125rem 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
font-family: 'Consolas', monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-row input:focus,
|
||||
.form-row textarea:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.form-row-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.hook-empty {
|
||||
padding: 0.5rem 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hook-add-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6366f1;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
padding: 0.375rem 0;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.hook-add-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn-xs {
|
||||
padding: 0.1875rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
</style>
|
||||
560
frontend/src/components/agents/McpManager.vue
Normal file
560
frontend/src/components/agents/McpManager.vue
Normal file
@@ -0,0 +1,560 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAgentsStore } from '../../stores/agents'
|
||||
import type { McpServerEntry } from '../../stores/agents'
|
||||
|
||||
const store = useAgentsStore()
|
||||
const showAddModal = ref(false)
|
||||
const editingServer = ref<string | null>(null)
|
||||
|
||||
const newServer = ref<McpServerEntry>({
|
||||
name: '',
|
||||
type: 'stdio',
|
||||
command: '',
|
||||
args: [],
|
||||
url: '',
|
||||
env: {},
|
||||
enabled: true
|
||||
})
|
||||
|
||||
const argsText = ref('')
|
||||
const envText = ref('')
|
||||
|
||||
function resetForm() {
|
||||
newServer.value = { name: '', type: 'stdio', command: '', args: [], url: '', env: {}, enabled: true }
|
||||
argsText.value = ''
|
||||
envText.value = ''
|
||||
editingServer.value = null
|
||||
}
|
||||
|
||||
function openAddModal() {
|
||||
resetForm()
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function openEditModal(server: McpServerEntry) {
|
||||
newServer.value = { ...server, args: [...(server.args || [])], env: { ...(server.env || {}) } }
|
||||
argsText.value = (server.args || []).join('\n')
|
||||
envText.value = Object.entries(server.env || {}).map(([k, v]) => `${k}=${v}`).join('\n')
|
||||
editingServer.value = server.name
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
function saveServer() {
|
||||
if (!newServer.value.name.trim()) return
|
||||
|
||||
// Parse args from text
|
||||
newServer.value.args = argsText.value.split('\n').map(s => s.trim()).filter(Boolean)
|
||||
|
||||
// Parse env from text
|
||||
const env: Record<string, string> = {}
|
||||
for (const line of envText.value.split('\n')) {
|
||||
const eq = line.indexOf('=')
|
||||
if (eq > 0) {
|
||||
env[line.slice(0, eq).trim()] = line.slice(eq + 1).trim()
|
||||
}
|
||||
}
|
||||
newServer.value.env = env
|
||||
|
||||
if (editingServer.value) {
|
||||
// Update existing
|
||||
const idx = store.mcpServers.findIndex(s => s.name === editingServer.value)
|
||||
if (idx >= 0) {
|
||||
store.mcpServers[idx] = { ...newServer.value }
|
||||
}
|
||||
} else {
|
||||
store.addMcpServer({ ...newServer.value })
|
||||
}
|
||||
|
||||
showAddModal.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function deleteServer(name: string) {
|
||||
store.removeMcpServer(name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mcp-manager">
|
||||
<!-- Header -->
|
||||
<div class="mcp-header">
|
||||
<div class="mcp-header-left">
|
||||
<h3>MCP Servers</h3>
|
||||
<span class="mcp-count">{{ store.mcpServers.length }} servers</span>
|
||||
<span class="mcp-path">.mcp.json</span>
|
||||
</div>
|
||||
<div class="mcp-header-right">
|
||||
<button class="btn btn-secondary" @click="openAddModal">+ Add Server</button>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="store.saving"
|
||||
@click="store.saveMcpServers()"
|
||||
>{{ store.saving ? 'Saving...' : 'Save' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.mcpsLoading" class="mcp-loading">Loading MCP servers...</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="store.error" class="mcp-error">{{ store.error }}</div>
|
||||
|
||||
<!-- Server cards -->
|
||||
<div v-if="!store.mcpsLoading" class="mcp-scroll">
|
||||
<div class="server-grid">
|
||||
<div v-for="server in store.mcpServers" :key="server.name" class="server-card">
|
||||
<div class="server-card-header">
|
||||
<div class="server-info">
|
||||
<span class="server-name">{{ server.name }}</span>
|
||||
<span class="server-type-badge" :class="server.type">{{ server.type }}</span>
|
||||
</div>
|
||||
<div class="server-actions">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ enabled: server.enabled }"
|
||||
@click="store.toggleMcpServer(server.name)"
|
||||
:title="server.enabled ? 'Disable' : 'Enable'"
|
||||
>
|
||||
<span class="toggle-dot"></span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="server-card-body">
|
||||
<div v-if="server.command" class="server-detail">
|
||||
<span class="detail-label">Command</span>
|
||||
<code>{{ server.command }}</code>
|
||||
</div>
|
||||
<div v-if="server.args?.length" class="server-detail">
|
||||
<span class="detail-label">Args</span>
|
||||
<code>{{ server.args.join(' ') }}</code>
|
||||
</div>
|
||||
<div v-if="server.url" class="server-detail">
|
||||
<span class="detail-label">URL</span>
|
||||
<code>{{ server.url }}</code>
|
||||
</div>
|
||||
<div v-if="server.env && Object.keys(server.env).length" class="server-detail">
|
||||
<span class="detail-label">Env</span>
|
||||
<span class="env-count">{{ Object.keys(server.env).length }} vars</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="server-card-footer">
|
||||
<button class="btn-link" @click="openEditModal(server)">Edit</button>
|
||||
<button class="btn-link danger" @click="deleteServer(server.name)">Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.mcpServers.length" class="mcp-empty">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>
|
||||
<p>No MCP servers configured</p>
|
||||
<span>Add a server to get started</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add/Edit Modal -->
|
||||
<div v-if="showAddModal" class="modal-overlay" @click.self="showAddModal = false">
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<h4>{{ editingServer ? 'Edit Server' : 'Add MCP Server' }}</h4>
|
||||
<button class="modal-close" @click="showAddModal = false">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>Name</label>
|
||||
<input type="text" v-model="newServer.name" placeholder="my-server" :disabled="!!editingServer" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Type</label>
|
||||
<select v-model="newServer.type">
|
||||
<option value="stdio">stdio</option>
|
||||
<option value="sse">sse</option>
|
||||
<option value="streamable-http">streamable-http</option>
|
||||
</select>
|
||||
</div>
|
||||
<div v-if="newServer.type === 'stdio'" class="form-group">
|
||||
<label>Command</label>
|
||||
<input type="text" v-model="newServer.command" placeholder="npx" />
|
||||
</div>
|
||||
<div v-if="newServer.type === 'stdio'" class="form-group">
|
||||
<label>Args (one per line)</label>
|
||||
<textarea v-model="argsText" placeholder="arg1 arg2" rows="3"></textarea>
|
||||
</div>
|
||||
<div v-if="newServer.type !== 'stdio'" class="form-group">
|
||||
<label>URL</label>
|
||||
<input type="text" v-model="newServer.url" placeholder="http://localhost:3000" />
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Environment (KEY=value, one per line)</label>
|
||||
<textarea v-model="envText" placeholder="API_KEY=xxx" rows="2"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn btn-secondary" @click="showAddModal = false">Cancel</button>
|
||||
<button class="btn btn-primary" @click="saveServer">{{ editingServer ? 'Update' : 'Add' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mcp-manager {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mcp-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mcp-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.mcp-header-left h3 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.mcp-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.mcp-path {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.mcp-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.mcp-loading,
|
||||
.mcp-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.mcp-empty span {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.mcp-error {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.mcp-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
/* Server grid */
|
||||
.server-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.server-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.server-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.server-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.server-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.server-type-badge {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.server-type-badge.stdio { background: rgba(99, 102, 241, 0.12); color: #6366f1; }
|
||||
.server-type-badge.sse { background: rgba(245, 158, 11, 0.12); color: #f59e0b; }
|
||||
.server-type-badge.streamable-http { background: rgba(16, 185, 129, 0.12); color: #10b981; }
|
||||
|
||||
.server-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.toggle-btn {
|
||||
width: 36px;
|
||||
height: 20px;
|
||||
border-radius: 10px;
|
||||
border: none;
|
||||
background: var(--bg-hover);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled {
|
||||
background: #22c55e;
|
||||
}
|
||||
|
||||
.toggle-dot {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: white;
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.toggle-btn.enabled .toggle-dot {
|
||||
transform: translateX(16px);
|
||||
}
|
||||
|
||||
.server-card-body {
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
|
||||
.server-detail {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.1875rem 0;
|
||||
}
|
||||
|
||||
.detail-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
min-width: 60px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.server-detail code {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Consolas', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.env-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.server-card-footer {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6366f1;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn-link.danger {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
/* Modal */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
width: 480px;
|
||||
max-height: 80vh;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h4 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
font-size: 1.25rem;
|
||||
cursor: pointer;
|
||||
padding: 0 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 1rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 0.25rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.375rem 0.5rem;
|
||||
font-size: 0.8125rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
font-family: 'Consolas', monospace;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.form-group textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
</style>
|
||||
212
frontend/src/components/agents/PluginsManager.vue
Normal file
212
frontend/src/components/agents/PluginsManager.vue
Normal file
@@ -0,0 +1,212 @@
|
||||
<script setup lang="ts">
|
||||
import { useAgentsStore } from '../../stores/agents'
|
||||
|
||||
const store = useAgentsStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="plugins-manager">
|
||||
<!-- Header -->
|
||||
<div class="plugins-header">
|
||||
<div class="plugins-header-left">
|
||||
<h3>Global Plugins</h3>
|
||||
<span class="plugins-count">{{ store.plugins.length }} plugins</span>
|
||||
<span class="plugins-path">~/.claude/plugins/marketplaces/</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.pluginsLoading" class="plugins-loading">Loading plugins...</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="store.error" class="plugins-error">{{ store.error }}</div>
|
||||
|
||||
<!-- Plugin cards -->
|
||||
<div v-if="!store.pluginsLoading" class="plugins-scroll">
|
||||
<div class="plugin-grid">
|
||||
<div v-for="plugin in store.plugins" :key="plugin.name" class="plugin-card">
|
||||
<div class="plugin-card-header">
|
||||
<div class="plugin-info">
|
||||
<span class="plugin-name">{{ plugin.name }}</span>
|
||||
<span class="plugin-installed" v-if="plugin.installed">Installed</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="plugin-card-body">
|
||||
<p v-if="plugin.description" class="plugin-desc">{{ plugin.description }}</p>
|
||||
<div v-if="plugin.author" class="plugin-meta">
|
||||
<span class="meta-label">Author</span>
|
||||
<span class="meta-value">{{ plugin.author }}</span>
|
||||
</div>
|
||||
<div v-if="plugin.mcpConfig" class="plugin-meta">
|
||||
<span class="meta-label">MCP</span>
|
||||
<span class="meta-value">{{ typeof plugin.mcpConfig === 'object' ? JSON.stringify(plugin.mcpConfig).slice(0, 80) : plugin.mcpConfig }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="!store.plugins.length" class="plugins-empty">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
||||
</svg>
|
||||
<p>No plugins installed</p>
|
||||
<span>Plugins from ~/.claude/plugins/marketplaces/ will appear here</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.plugins-manager {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugins-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.plugins-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.plugins-header-left h3 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plugins-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.plugins-path {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.plugins-loading,
|
||||
.plugins-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.plugins-empty span {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.plugins-error {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.plugins-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.plugin-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.plugin-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.plugin-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.plugin-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.plugin-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.plugin-installed {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(34, 197, 94, 0.12);
|
||||
color: #22c55e;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.plugin-card-body {
|
||||
padding: 0.625rem 0.75rem;
|
||||
}
|
||||
|
||||
.plugin-desc {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.plugin-meta {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.5rem;
|
||||
padding: 0.125rem 0;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
min-width: 50px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.meta-value {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-secondary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
316
frontend/src/components/agents/SkillsManager.vue
Normal file
316
frontend/src/components/agents/SkillsManager.vue
Normal file
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import { useAgentsStore } from '../../stores/agents'
|
||||
import type { SkillEntry } from '../../stores/agents'
|
||||
|
||||
const store = useAgentsStore()
|
||||
|
||||
function selectSkill(skill: SkillEntry) {
|
||||
store.selectedSkill = skill
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="skills-manager">
|
||||
<!-- Header -->
|
||||
<div class="skills-header">
|
||||
<h3>Skills</h3>
|
||||
<span class="skills-count">{{ store.skills.length }} skills</span>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.skillsLoading" class="skills-loading">Loading skills...</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="store.error" class="skills-error">{{ store.error }}</div>
|
||||
|
||||
<!-- Two-column layout -->
|
||||
<div v-if="!store.skillsLoading" class="skills-layout">
|
||||
<!-- Left: skills list -->
|
||||
<aside class="skills-list">
|
||||
<button
|
||||
v-for="skill in store.skills"
|
||||
:key="skill.name"
|
||||
class="skill-item"
|
||||
:class="{ active: store.selectedSkill?.name === skill.name }"
|
||||
@click="selectSkill(skill)"
|
||||
>
|
||||
<div class="skill-item-main">
|
||||
<span class="skill-name">{{ skill.name }}</span>
|
||||
<span v-if="skill.references.length" class="skill-refs">{{ skill.references.length }} files</span>
|
||||
</div>
|
||||
<p v-if="skill.description" class="skill-desc">{{ skill.description }}</p>
|
||||
</button>
|
||||
|
||||
<div v-if="!store.skills.length" class="skills-empty-list">
|
||||
<p>No skills found</p>
|
||||
<span>Add a SKILL.md to .claude/skills/*/</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<!-- Right: skill detail -->
|
||||
<main class="skill-detail">
|
||||
<template v-if="store.selectedSkill">
|
||||
<div class="skill-detail-header">
|
||||
<h4>{{ store.selectedSkill.name }}</h4>
|
||||
<span class="skill-detail-path" :title="store.selectedSkill.path">{{ store.selectedSkill.path }}</span>
|
||||
</div>
|
||||
|
||||
<!-- SKILL.md content -->
|
||||
<div class="skill-md-content">
|
||||
<pre>{{ store.selectedSkill.skillMdContent }}</pre>
|
||||
</div>
|
||||
|
||||
<!-- References -->
|
||||
<div v-if="store.selectedSkill.references.length" class="skill-references">
|
||||
<h5>Referenced Files</h5>
|
||||
<div class="ref-list">
|
||||
<div v-for="ref in store.selectedSkill.references" :key="ref.name" class="ref-item">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/>
|
||||
</svg>
|
||||
<span>{{ ref.name }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-else class="skill-detail-empty">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"/><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"/>
|
||||
</svg>
|
||||
<p>Select a skill to view details</p>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.skills-manager {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skills-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skills-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.skills-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.skills-loading {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.skills-error {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
/* Two-column layout */
|
||||
.skills-layout {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.skills-list {
|
||||
width: 280px;
|
||||
min-width: 280px;
|
||||
border-right: 1px solid var(--border-color);
|
||||
overflow-y: auto;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.skill-item {
|
||||
width: 100%;
|
||||
display: block;
|
||||
padding: 0.625rem 0.875rem;
|
||||
background: none;
|
||||
border: none;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
|
||||
.skill-item:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.skill-item.active {
|
||||
background: rgba(99, 102, 241, 0.08);
|
||||
}
|
||||
|
||||
.skill-item-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.skill-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.skill-refs {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.0625rem 0.375rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skill-desc {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.4;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.skills-empty-list {
|
||||
padding: 2rem 1rem;
|
||||
text-align: center;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.skills-empty-list p {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.skills-empty-list span {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* Skill detail */
|
||||
.skill-detail {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.skill-detail-header {
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.skill-detail-header h4 {
|
||||
margin: 0 0 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.skill-detail-path {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Consolas', monospace;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.skill-md-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
.skill-md-content pre {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
font-family: 'Consolas', 'Monaco', monospace;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
word-wrap: break-word;
|
||||
}
|
||||
|
||||
.skill-references {
|
||||
border-top: 1px solid var(--border-color);
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.skill-references h5 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.ref-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.ref-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.ref-item svg {
|
||||
flex-shrink: 0;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.skill-detail-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.skill-detail-empty p {
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
484
frontend/src/components/agents/ToolsManager.vue
Normal file
484
frontend/src/components/agents/ToolsManager.vue
Normal file
@@ -0,0 +1,484 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAgentsStore } from '../../stores/agents'
|
||||
import type { ToolEntry } from '../../stores/agents'
|
||||
|
||||
const store = useAgentsStore()
|
||||
const newRuleFor = ref<string | null>(null)
|
||||
const newRuleValue = ref('')
|
||||
|
||||
function statusColor(status: string): string {
|
||||
if (status === 'allow') return '#22c55e'
|
||||
if (status === 'deny') return '#ef4444'
|
||||
return 'var(--text-muted)'
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
if (status === 'allow') return 'Allow'
|
||||
if (status === 'deny') return 'Deny'
|
||||
return 'Ask'
|
||||
}
|
||||
|
||||
function submitRule(toolKey: string) {
|
||||
if (newRuleValue.value.trim()) {
|
||||
store.addToolRule(toolKey, newRuleValue.value.trim())
|
||||
newRuleValue.value = ''
|
||||
newRuleFor.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function isParameterizable(name: string): boolean {
|
||||
return ['Bash', 'WebFetch', 'Skill', 'WebSearch'].includes(name)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="tools-manager">
|
||||
<!-- Header -->
|
||||
<div class="tools-header">
|
||||
<div class="tools-header-left">
|
||||
<h3>Tool Permissions</h3>
|
||||
<span class="tools-count">{{ store.filteredTools.length }} tools</span>
|
||||
<span v-if="store.configFile" class="config-path">{{ store.configFile }}</span>
|
||||
</div>
|
||||
<div class="tools-header-right">
|
||||
<input
|
||||
type="text"
|
||||
class="tools-search"
|
||||
placeholder="Filter tools..."
|
||||
v-model="store.toolsFilter"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="!store.configDirty || store.saving"
|
||||
@click="store.savePermissions()"
|
||||
>{{ store.saving ? 'Saving...' : 'Save Permissions' }}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="store.toolsLoading" class="tools-loading">Loading tools...</div>
|
||||
|
||||
<!-- Error -->
|
||||
<div v-if="store.error" class="tools-error">{{ store.error }}</div>
|
||||
|
||||
<!-- Tools list -->
|
||||
<div v-if="!store.toolsLoading" class="tools-scroll">
|
||||
<!-- Base tools -->
|
||||
<div v-if="store.toolsByCategory.base.length" class="tool-group">
|
||||
<div class="tool-group-header">
|
||||
<span class="tool-group-label">Base Tools</span>
|
||||
<span class="tool-group-count">{{ store.toolsByCategory.base.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tool-cards">
|
||||
<div v-for="tool in store.toolsByCategory.base" :key="tool.fullKey" class="tool-card">
|
||||
<div class="tool-card-main">
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">{{ tool.name }}</span>
|
||||
<span class="tool-badge base">base</span>
|
||||
</div>
|
||||
<button
|
||||
class="status-toggle"
|
||||
:style="{ '--status-color': statusColor(tool.status) }"
|
||||
@click="store.cycleToolStatus(tool.fullKey)"
|
||||
:title="`Click to cycle: ${tool.status}`"
|
||||
>
|
||||
<span class="status-dot" :class="tool.status"></span>
|
||||
<span class="status-text">{{ statusLabel(tool.status) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Rules for parameterizable tools -->
|
||||
<div v-if="isParameterizable(tool.name) && tool.status !== 'ask'" class="tool-rules">
|
||||
<div v-for="(rule, idx) in tool.rules" :key="idx" class="rule-item">
|
||||
<code>{{ tool.name }}({{ rule }})</code>
|
||||
<button class="rule-remove" @click="store.removeToolRule(tool.fullKey, rule)">×</button>
|
||||
</div>
|
||||
<div v-if="newRuleFor === tool.fullKey" class="rule-add-form">
|
||||
<input
|
||||
type="text"
|
||||
class="rule-input"
|
||||
v-model="newRuleValue"
|
||||
placeholder="e.g. git:* or domain:example.com"
|
||||
@keydown.enter="submitRule(tool.fullKey)"
|
||||
@keydown.escape="newRuleFor = null"
|
||||
/>
|
||||
<button class="btn-sm" @click="submitRule(tool.fullKey)">Add</button>
|
||||
</div>
|
||||
<button v-else class="rule-add-btn" @click="newRuleFor = tool.fullKey; newRuleValue = ''">
|
||||
+ Add rule
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MCP tools grouped by server -->
|
||||
<div
|
||||
v-for="(tools, server) in store.toolsByCategory.mcpGroups"
|
||||
:key="server"
|
||||
class="tool-group"
|
||||
>
|
||||
<div class="tool-group-header">
|
||||
<span class="tool-group-label">MCP: {{ server }}</span>
|
||||
<span class="tool-group-count">{{ tools.length }}</span>
|
||||
</div>
|
||||
|
||||
<div class="tool-cards">
|
||||
<div v-for="tool in tools" :key="tool.fullKey" class="tool-card">
|
||||
<div class="tool-card-main">
|
||||
<div class="tool-info">
|
||||
<span class="tool-name">{{ tool.name }}</span>
|
||||
<span class="tool-badge mcp">mcp</span>
|
||||
<span v-if="tool.host" class="tool-host">{{ tool.host }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="status-toggle"
|
||||
:style="{ '--status-color': statusColor(tool.status) }"
|
||||
@click="store.cycleToolStatus(tool.fullKey)"
|
||||
:title="`Click to cycle: ${tool.status}`"
|
||||
>
|
||||
<span class="status-dot" :class="tool.status"></span>
|
||||
<span class="status-text">{{ statusLabel(tool.status) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Empty state -->
|
||||
<div v-if="!store.toolsByCategory.base.length && !Object.keys(store.toolsByCategory.mcpGroups).length" class="tools-empty">
|
||||
<p>No tools found</p>
|
||||
<span>Tool permissions will appear here when configured</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tools-manager {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tools-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
background: var(--bg-secondary);
|
||||
gap: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tools-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.tools-header-left h3 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.tools-count {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
padding: 0.125rem 0.5rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.config-path {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.tools-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tools-search {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.tools-search:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.tools-loading,
|
||||
.tools-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.tools-empty span {
|
||||
font-size: 0.6875rem;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.tools-error {
|
||||
padding: 0.625rem 1.25rem;
|
||||
background: rgba(239, 68, 68, 0.1);
|
||||
color: #ef4444;
|
||||
font-size: 0.8125rem;
|
||||
border-bottom: 1px solid rgba(239, 68, 68, 0.2);
|
||||
}
|
||||
|
||||
.tools-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 1rem 1.25rem;
|
||||
}
|
||||
|
||||
/* Tool groups */
|
||||
.tool-group {
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.tool-group-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-group-label {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.tool-group-count {
|
||||
font-size: 0.625rem;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
background: var(--bg-hover);
|
||||
border-radius: 999px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Tool cards */
|
||||
.tool-cards {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.tool-card {
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tool-card-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.tool-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tool-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.tool-badge {
|
||||
font-size: 0.5625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 999px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.tool-badge.base {
|
||||
background: rgba(99, 102, 241, 0.12);
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.tool-badge.mcp {
|
||||
background: rgba(139, 92, 246, 0.12);
|
||||
color: #8b5cf6;
|
||||
}
|
||||
|
||||
.tool-host {
|
||||
font-size: 0.625rem;
|
||||
color: var(--text-muted);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
/* Status toggle */
|
||||
.status-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.25rem 0.625rem;
|
||||
background: transparent;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 999px;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.status-toggle:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
|
||||
.status-dot.allow { background: #22c55e; }
|
||||
.status-dot.deny { background: #ef4444; }
|
||||
.status-dot.ask { background: var(--text-muted); }
|
||||
|
||||
.status-text {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Rules */
|
||||
.tool-rules {
|
||||
padding: 0.375rem 0.75rem 0.5rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
}
|
||||
|
||||
.rule-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.1875rem 0;
|
||||
}
|
||||
|
||||
.rule-item code {
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-secondary);
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.rule-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
font-size: 0.875rem;
|
||||
padding: 0 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.rule-remove:hover {
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.rule-add-form {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.rule-input {
|
||||
flex: 1;
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
outline: none;
|
||||
font-family: 'Consolas', monospace;
|
||||
}
|
||||
|
||||
.rule-input:focus {
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: 0.25rem 0.5rem;
|
||||
font-size: 0.6875rem;
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.rule-add-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6366f1;
|
||||
font-size: 0.6875rem;
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
|
||||
.rule-add-btn:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 0.3125rem 0.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-color);
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: #6366f1;
|
||||
color: white;
|
||||
border-color: #6366f1;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background: #4f46e5;
|
||||
border-color: #4f46e5;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user