kcg-code 0.1.0 → 0.2.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/README.md +2 -0
- package/package.json +2 -1
- package/src/cli/__tests__/daemon.test.ts +84 -0
- package/src/cli/__tests__/dashboard.test.ts +170 -8
- package/src/cli/__tests__/keep-awake.test.ts +44 -0
- package/src/cli/__tests__/port.test.ts +86 -0
- package/src/cli/args.ts +69 -6
- package/src/cli/attach.ts +154 -0
- package/src/cli/commands.ts +174 -21
- package/src/cli/daemon.ts +275 -0
- package/src/cli/dashboard.tsx +880 -159
- package/src/cli/index.ts +10 -0
- package/src/cli/ink-react.ts +26 -0
- package/src/cli/keep-awake.ts +148 -0
- package/src/cli/port.ts +92 -0
- package/src/cli/theme.ts +6 -6
- package/src/components/layout/AppShell.tsx +8 -3
- package/src/components/projects/HomeChat.tsx +85 -48
- package/src/components/projects/HomeRecentSessions.tsx +90 -0
- package/src/components/projects/ProjectInstructions.tsx +27 -38
- package/src/components/projects/ProjectSidePanel.tsx +382 -316
- package/src/components/sessions/AgentLogo.tsx +42 -0
- package/src/components/sessions/AgentPicker.tsx +13 -4
- package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
- package/src/components/sessions/ContextPanel.tsx +159 -59
- package/src/components/sessions/ModelPicker.tsx +65 -27
- package/src/components/sessions/ModelSearchList.tsx +137 -57
- package/src/components/sessions/NewSessionDialog.tsx +100 -117
- package/src/components/sessions/SessionComposer.tsx +2 -1
- package/src/components/sessions/SessionHeader.tsx +1 -0
- package/src/components/sessions/SessionList.tsx +173 -66
- package/src/components/sessions/SessionRow.tsx +176 -76
- package/src/components/sessions/SessionView.tsx +17 -13
- package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
- package/src/components/settings/LhrRemoteAccess.tsx +20 -25
- package/src/components/settings/RemoteAccessGroup.tsx +242 -267
- package/src/components/settings/prefs.tsx +69 -1
- package/src/components/skills/InstallNotifications.tsx +4 -3
- package/src/components/skills/InstallSkillDialog.tsx +256 -99
- package/src/components/skills/SkillCards.tsx +52 -8
- package/src/components/ui/adw-dialog.tsx +114 -0
- package/src/components/ui/adw-toggle-group.tsx +75 -0
- package/src/components/ui/search-entry.tsx +70 -0
- package/src/config.ts +32 -3
- package/src/hooks/useAgentPicker.ts +6 -2
- package/src/hooks/useSessionList.ts +95 -2
- package/src/lib/__tests__/project-extensions.test.ts +32 -1
- package/src/lib/__tests__/project-overview.test.ts +15 -0
- package/src/lib/__tests__/routes.test.ts +2 -2
- package/src/lib/__tests__/session-selection.test.ts +41 -0
- package/src/lib/__tests__/skill-installs.test.ts +1 -0
- package/src/lib/__tests__/skills-catalog.test.ts +36 -8
- package/src/lib/agent-types.ts +49 -0
- package/src/lib/project-extensions.ts +66 -4
- package/src/lib/project-overview.ts +14 -8
- package/src/lib/routes.ts +13 -4
- package/src/lib/session-selection.ts +40 -0
- package/src/lib/skill-installs.ts +3 -1
- package/src/lib/skills-catalog.ts +56 -17
- package/src/pages/projects/ProjectDetailPage.tsx +13 -12
- package/src/pages/settings/SettingsPage.tsx +212 -145
- package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
- package/src/pages/skills/SkillsPage.tsx +89 -45
- package/src/server/__tests__/app.test.ts +15 -2
- package/src/server/app.ts +13 -1
- package/src/server/routes/projects.routes.ts +14 -4
- package/src/server/routes/sessions.routes.ts +1 -1
- package/src/server/routes/skills.routes.ts +24 -5
- package/src/server/services/__tests__/claude-code.test.ts +609 -0
- package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
- package/src/server/services/__tests__/claude-skills.test.ts +63 -0
- package/src/server/services/__tests__/session-manager.test.ts +40 -0
- package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
- package/src/server/services/__tests__/skills-registry.test.ts +6 -0
- package/src/server/services/claude-code-client.ts +1093 -0
- package/src/server/services/claude-code-translate.ts +370 -0
- package/src/server/services/claude-mcp.ts +122 -0
- package/src/server/services/claude-skills.ts +102 -0
- package/src/server/services/session-manager.ts +117 -52
- package/src/server/services/skill-install-jobs.ts +21 -8
- package/src/server/services/skills-registry.ts +18 -6
- package/src/types/message.ts +1 -1
- package/src/components/projects/PanelSection.tsx +0 -102
|
@@ -1,73 +1,107 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Panel samping halaman Project —
|
|
3
|
-
*
|
|
2
|
+
* Panel samping halaman Project — Instructions, MCP server, dan Skills dalam
|
|
3
|
+
* satu kolom di kanan daftar Session.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
5
|
+
* Gaya libadwaita (GNOME Settings), memakai primitif `settings/prefs`:
|
|
6
|
+
* tiap seksi = AdwPreferencesGroup (judul + deskripsi di luar kotak, aksi
|
|
7
|
+
* datar di kanan judul) berisi "boxed list". Baris = AdwActionRow /
|
|
8
|
+
* AdwExpanderRow; kolom cari & filter (GtkSearchEntry, AdwToggleGroup) ada di
|
|
9
|
+
* antara judul dan kotak.
|
|
10
|
+
*
|
|
11
|
+
* MCP & Skills dibaca dari KEDUA agent (OpenCode & Claude Code) lalu
|
|
12
|
+
* digabung per nama, sama seperti halaman Skills:
|
|
13
|
+
* - MCP: `GET /api/projects/:id/mcp?agent=` — status per agent (logo +
|
|
14
|
+
* label) di subjudul; yang bermasalah paling atas, error tampil inline.
|
|
15
|
+
* Server Claude Code dibaca dari config (`.mcp.json`, `~/.claude.json`);
|
|
16
|
+
* statusnya "Not checked" sampai ada Session Claude Code yang berjalan.
|
|
17
|
+
* - Skills: `GET /api/projects/:id/skills?agent=` — cari (bila banyak) +
|
|
18
|
+
* filter asal; logo agent yang memuat skill; baris dibuka untuk deskripsi
|
|
19
|
+
* lengkap & path. Tombol "+" membuka halaman Skills untuk Project ini.
|
|
20
|
+
* Satu agent gagal dibaca tidak menggagalkan yang lain (baris catatan).
|
|
21
|
+
* Tiap seksi memuat datanya sendiri; tombol reload memuat ulang keduanya.
|
|
14
22
|
*/
|
|
15
|
-
import {
|
|
16
|
-
ChevronDownIcon,
|
|
17
|
-
PlugIcon,
|
|
18
|
-
RefreshCwIcon,
|
|
19
|
-
SearchIcon,
|
|
20
|
-
SparklesIcon,
|
|
21
|
-
XIcon,
|
|
22
|
-
} from "lucide-react";
|
|
23
|
+
import { PlugIcon, PlusIcon, RefreshCwIcon, SparklesIcon, TriangleAlertIcon } from "lucide-react";
|
|
23
24
|
import { type ReactNode, useCallback, useEffect, useMemo, useState } from "react";
|
|
24
|
-
import { PanelBody, PanelHint, PanelList, PanelSection } from "@/components/projects/PanelSection";
|
|
25
25
|
import { ProjectInstructions } from "@/components/projects/ProjectInstructions";
|
|
26
|
+
import { AgentLogo } from "@/components/sessions/AgentLogo";
|
|
27
|
+
import { ActionRow, ExpanderRow, PrefsGroup, RowBadge } from "@/components/settings/prefs";
|
|
28
|
+
import { AgentBadges } from "@/components/skills/SkillCards";
|
|
29
|
+
import { AdwToggleGroup } from "@/components/ui/adw-toggle-group";
|
|
26
30
|
import { Button } from "@/components/ui/button";
|
|
31
|
+
import { SearchEntry } from "@/components/ui/search-entry";
|
|
27
32
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
33
|
+
import { useRouter } from "@/hooks/useRouter";
|
|
34
|
+
import { AGENT_TYPE_LABELS, AGENT_TYPES, agentQuery } from "@/lib/agent-types";
|
|
28
35
|
import { ApiError, apiFetch } from "@/lib/api";
|
|
29
36
|
import {
|
|
30
|
-
|
|
37
|
+
describeMcpEntries,
|
|
31
38
|
filterSkills,
|
|
32
39
|
groupSkills,
|
|
33
40
|
MCP_STATUS_LABEL,
|
|
34
|
-
|
|
41
|
+
type McpServerEntry,
|
|
42
|
+
mergeMcpServers,
|
|
35
43
|
} from "@/lib/project-extensions";
|
|
44
|
+
import { skillsPath } from "@/lib/routes";
|
|
45
|
+
import { type InstalledSkill, mergeInstalledSkills } from "@/lib/skills-catalog";
|
|
36
46
|
import { cn } from "@/lib/utils";
|
|
37
47
|
import type { McpServerInfo, SkillInfo, SkillSource } from "@/server/services/opencode-client";
|
|
38
|
-
import type { Project } from "@/types";
|
|
48
|
+
import type { AgentType, McpServerState, Project } from "@/types";
|
|
39
49
|
|
|
40
50
|
/** Skill ditampilkan terbatas dulu; sisanya lewat "Show more". */
|
|
41
51
|
const SKILL_PREVIEW = 5;
|
|
42
|
-
/**
|
|
52
|
+
/** Kolom cari muncul hanya bila skill cukup banyak. */
|
|
43
53
|
const SKILL_SEARCH_THRESHOLD = 8;
|
|
44
54
|
|
|
55
|
+
/** Hasil per agent; `failed` = agent yang gagal dibaca (yang lain tetap tampil). */
|
|
56
|
+
interface AgentLists<T> {
|
|
57
|
+
byAgent: Partial<Record<AgentType, T[]>>;
|
|
58
|
+
failed: { agent: AgentType; message: string }[];
|
|
59
|
+
}
|
|
60
|
+
|
|
45
61
|
type Load<T> =
|
|
46
62
|
| { phase: "loading" }
|
|
47
63
|
| { phase: "error"; message: string }
|
|
48
64
|
| { phase: "ready"; data: T };
|
|
49
65
|
|
|
50
|
-
/**
|
|
51
|
-
|
|
52
|
-
|
|
66
|
+
/**
|
|
67
|
+
* Muat satu endpoint list untuk setiap agent (`?agent=`) bersamaan.
|
|
68
|
+
* Error hanya bila SEMUA agent gagal. `reloadKey` berubah -> muat ulang.
|
|
69
|
+
*/
|
|
70
|
+
function useAgentLists<T>(path: string, key: string, reloadKey: number): Load<AgentLists<T>> {
|
|
71
|
+
const [state, setState] = useState<Load<AgentLists<T>>>({ phase: "loading" });
|
|
53
72
|
useEffect(() => {
|
|
54
73
|
// `reloadKey` hanya pemicu muat ulang.
|
|
55
74
|
void reloadKey;
|
|
56
75
|
let cancelled = false;
|
|
57
76
|
setState({ phase: "loading" });
|
|
58
|
-
|
|
59
|
-
.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
77
|
+
void Promise.allSettled(
|
|
78
|
+
AGENT_TYPES.map((a) =>
|
|
79
|
+
apiFetch(`${path}${agentQuery(a)}`).then(async (res) => {
|
|
80
|
+
const body = (await res.json()) as Record<string, T[]>;
|
|
81
|
+
return body[key] ?? [];
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
).then((results) => {
|
|
85
|
+
if (cancelled) return;
|
|
86
|
+
const data: AgentLists<T> = { byAgent: {}, failed: [] };
|
|
87
|
+
results.forEach((r, i) => {
|
|
88
|
+
const agent = AGENT_TYPES[i] as AgentType;
|
|
89
|
+
if (r.status === "fulfilled") data.byAgent[agent] = r.value;
|
|
90
|
+
else {
|
|
91
|
+
const message = r.reason instanceof ApiError ? r.reason.message : "Failed to load";
|
|
92
|
+
data.failed.push({ agent, message });
|
|
93
|
+
}
|
|
66
94
|
});
|
|
95
|
+
if (data.failed.length === AGENT_TYPES.length) {
|
|
96
|
+
setState({ phase: "error", message: data.failed[0]?.message ?? "Failed to load" });
|
|
97
|
+
} else {
|
|
98
|
+
setState({ phase: "ready", data });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
67
101
|
return () => {
|
|
68
102
|
cancelled = true;
|
|
69
103
|
};
|
|
70
|
-
}, [
|
|
104
|
+
}, [path, key, reloadKey]);
|
|
71
105
|
return state;
|
|
72
106
|
}
|
|
73
107
|
|
|
@@ -79,168 +113,227 @@ export interface ProjectSidePanelProps {
|
|
|
79
113
|
export function ProjectSidePanel({ project, onProjectChange }: ProjectSidePanelProps) {
|
|
80
114
|
const [reloadKey, setReloadKey] = useState(0);
|
|
81
115
|
const reload = useCallback(() => setReloadKey((k) => k + 1), []);
|
|
82
|
-
const mcp =
|
|
83
|
-
const skills =
|
|
116
|
+
const mcp = useAgentLists<McpServerInfo>(`/api/projects/${project.id}/mcp`, "mcp", reloadKey);
|
|
117
|
+
const skills = useAgentLists<SkillInfo>(
|
|
84
118
|
`/api/projects/${project.id}/skills`,
|
|
85
119
|
"skills",
|
|
86
120
|
reloadKey,
|
|
87
121
|
);
|
|
88
122
|
|
|
89
123
|
return (
|
|
90
|
-
<div className="flex flex-col gap-
|
|
124
|
+
<div className="flex flex-col gap-6">
|
|
91
125
|
<ProjectInstructions project={project} onSaved={onProjectChange} />
|
|
92
|
-
<
|
|
93
|
-
<
|
|
126
|
+
<McpGroup state={mcp} onReload={reload} />
|
|
127
|
+
<SkillsGroup state={skills} projectId={project.id} />
|
|
94
128
|
</div>
|
|
95
129
|
);
|
|
96
130
|
}
|
|
97
131
|
|
|
98
|
-
|
|
132
|
+
// ------------------------------------------------------------- shared ----
|
|
133
|
+
|
|
134
|
+
/** Tombol datar bundar di kanan judul grup (aksi header AdwPreferencesGroup). */
|
|
135
|
+
function FlatIconButton({
|
|
136
|
+
label,
|
|
137
|
+
onClick,
|
|
138
|
+
disabled,
|
|
139
|
+
children,
|
|
140
|
+
}: {
|
|
141
|
+
label: string;
|
|
142
|
+
onClick: () => void;
|
|
143
|
+
disabled?: boolean;
|
|
144
|
+
children: ReactNode;
|
|
145
|
+
}) {
|
|
99
146
|
return (
|
|
100
|
-
<
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
</
|
|
147
|
+
<Button
|
|
148
|
+
type="button"
|
|
149
|
+
variant="ghost"
|
|
150
|
+
size="icon"
|
|
151
|
+
onClick={onClick}
|
|
152
|
+
disabled={disabled}
|
|
153
|
+
aria-label={label}
|
|
154
|
+
title={label}
|
|
155
|
+
className="size-8 rounded-full text-foreground/80"
|
|
156
|
+
>
|
|
157
|
+
{children}
|
|
158
|
+
</Button>
|
|
112
159
|
);
|
|
113
160
|
}
|
|
114
161
|
|
|
115
|
-
|
|
162
|
+
/** Baris placeholder saat memuat — tinggi sama dengan ActionRow. */
|
|
163
|
+
function SkeletonRows({ rows }: { rows: number }) {
|
|
164
|
+
return Array.from({ length: rows }, (_, i) => (
|
|
165
|
+
// biome-ignore lint/suspicious/noArrayIndexKey: placeholder statis
|
|
166
|
+
<li key={i} className="flex min-h-14 flex-col justify-center gap-1.5 px-4 py-2.5" aria-hidden>
|
|
167
|
+
<Skeleton className="h-4" style={{ width: `${60 - i * 12}%` }} />
|
|
168
|
+
<Skeleton className="h-3 w-1/3" />
|
|
169
|
+
</li>
|
|
170
|
+
));
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Baris error dengan tombol coba lagi. */
|
|
174
|
+
function ErrorRow({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
|
116
175
|
return (
|
|
117
|
-
<
|
|
118
|
-
<
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
176
|
+
<ActionRow
|
|
177
|
+
prefix={<TriangleAlertIcon className="size-[18px] text-destructive" aria-hidden />}
|
|
178
|
+
title="Couldn't load"
|
|
179
|
+
subtitle={message}
|
|
180
|
+
suffix={
|
|
181
|
+
onRetry && (
|
|
182
|
+
<Button type="button" variant="outline" size="sm" onClick={onRetry}>
|
|
183
|
+
Retry
|
|
184
|
+
</Button>
|
|
185
|
+
)
|
|
186
|
+
}
|
|
187
|
+
/>
|
|
125
188
|
);
|
|
126
189
|
}
|
|
127
190
|
|
|
128
|
-
/**
|
|
129
|
-
function
|
|
191
|
+
/** Baris catatan bila sebagian agent gagal dibaca. */
|
|
192
|
+
function PartialFailureRow({ failed }: { failed: AgentLists<unknown>["failed"] }) {
|
|
193
|
+
if (failed.length === 0) return null;
|
|
130
194
|
return (
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
"
|
|
134
|
-
|
|
135
|
-
)}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
{children}
|
|
139
|
-
</span>
|
|
195
|
+
<ActionRow
|
|
196
|
+
prefix={
|
|
197
|
+
<TriangleAlertIcon className="size-[18px] text-amber-600 dark:text-amber-400" aria-hidden />
|
|
198
|
+
}
|
|
199
|
+
title={`Couldn't read ${failed.map((f) => AGENT_TYPE_LABELS[f.agent]).join(", ")}`}
|
|
200
|
+
subtitle={failed[0]?.message}
|
|
201
|
+
/>
|
|
140
202
|
);
|
|
141
203
|
}
|
|
142
204
|
|
|
143
205
|
// ---------------------------------------------------------------- MCP ----
|
|
144
206
|
|
|
145
|
-
/**
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
207
|
+
/** Warna teks status MCP per agent. */
|
|
208
|
+
const MCP_TEXT: Record<McpServerState["status"], string> = {
|
|
209
|
+
connected: "text-emerald-700 dark:text-emerald-400",
|
|
210
|
+
failed: "text-destructive",
|
|
211
|
+
needs_auth: "text-amber-700 dark:text-amber-400",
|
|
212
|
+
needs_client_registration: "text-amber-700 dark:text-amber-400",
|
|
213
|
+
disabled: "text-muted-foreground",
|
|
214
|
+
unknown: "text-muted-foreground",
|
|
153
215
|
};
|
|
154
216
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
217
|
+
/** Warna ikon baris = status paling bermasalah. */
|
|
218
|
+
const MCP_ICON: Record<McpServerState["status"], string> = {
|
|
219
|
+
connected: "text-emerald-600 dark:text-emerald-400",
|
|
220
|
+
failed: "text-destructive",
|
|
221
|
+
needs_auth: "text-amber-600 dark:text-amber-400",
|
|
222
|
+
needs_client_registration: "text-amber-600 dark:text-amber-400",
|
|
223
|
+
disabled: "text-muted-foreground/60",
|
|
224
|
+
unknown: "text-muted-foreground",
|
|
162
225
|
};
|
|
163
226
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
227
|
+
/**
|
|
228
|
+
* Label status per agent. `unknown` di Claude Code berarti status belum
|
|
229
|
+
* pernah dilaporkan proses `claude` (server dibaca dari config).
|
|
230
|
+
*/
|
|
231
|
+
function mcpAgentLabel(agent: AgentType, status: McpServerState["status"]): string {
|
|
232
|
+
if (agent === "claude-code" && status === "unknown") return "Not checked";
|
|
233
|
+
return MCP_STATUS_LABEL[status];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function McpGroup({
|
|
237
|
+
state,
|
|
238
|
+
onReload,
|
|
239
|
+
}: {
|
|
240
|
+
state: Load<AgentLists<McpServerInfo>>;
|
|
241
|
+
onReload: () => void;
|
|
242
|
+
}) {
|
|
243
|
+
const entries = useMemo(
|
|
244
|
+
() => (state.phase === "ready" ? mergeMcpServers(state.data.byAgent) : []),
|
|
172
245
|
[state],
|
|
173
246
|
);
|
|
174
247
|
|
|
175
248
|
return (
|
|
176
|
-
<
|
|
177
|
-
id="mcp"
|
|
249
|
+
<PrefsGroup
|
|
178
250
|
title="MCP servers"
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
type="button"
|
|
184
|
-
variant="ghost"
|
|
185
|
-
size="icon-xs"
|
|
251
|
+
description={state.phase === "ready" ? describeMcpEntries(entries) : "Loading…"}
|
|
252
|
+
suffix={
|
|
253
|
+
<FlatIconButton
|
|
254
|
+
label="Reload MCP servers and skills"
|
|
186
255
|
onClick={onReload}
|
|
187
256
|
disabled={state.phase === "loading"}
|
|
188
|
-
aria-label="Reload MCP servers and skills"
|
|
189
|
-
title="Reload"
|
|
190
|
-
className="size-8 text-muted-foreground"
|
|
191
257
|
>
|
|
192
258
|
<RefreshCwIcon className={cn(state.phase === "loading" && "animate-spin")} />
|
|
193
|
-
</
|
|
259
|
+
</FlatIconButton>
|
|
194
260
|
}
|
|
195
261
|
>
|
|
196
262
|
{state.phase === "loading" ? (
|
|
197
|
-
<
|
|
263
|
+
<SkeletonRows rows={2} />
|
|
198
264
|
) : state.phase === "error" ? (
|
|
199
|
-
<
|
|
200
|
-
) : sorted.length === 0 ? (
|
|
201
|
-
<PanelHint>
|
|
202
|
-
No MCP servers yet. Add them under <code className="font-mono">mcp</code> in
|
|
203
|
-
opencode.json, then reload.
|
|
204
|
-
</PanelHint>
|
|
265
|
+
<ErrorRow message={state.message} onRetry={onReload} />
|
|
205
266
|
) : (
|
|
206
|
-
|
|
207
|
-
{
|
|
208
|
-
<
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
className={cn(
|
|
223
|
-
"shrink-0 rounded-full px-2 py-0.5 text-[11px] font-medium leading-4",
|
|
224
|
-
MCP_PILL[server.status],
|
|
225
|
-
)}
|
|
226
|
-
>
|
|
227
|
-
{MCP_STATUS_LABEL[server.status]}
|
|
228
|
-
</span>
|
|
229
|
-
</div>
|
|
230
|
-
{server.error && (
|
|
231
|
-
<p
|
|
232
|
-
className="line-clamp-2 font-mono text-[11px] leading-relaxed break-all text-destructive/90"
|
|
233
|
-
title={server.error}
|
|
234
|
-
>
|
|
235
|
-
{server.error}
|
|
236
|
-
</p>
|
|
237
|
-
)}
|
|
238
|
-
</div>
|
|
239
|
-
</li>
|
|
240
|
-
))}
|
|
241
|
-
</PanelList>
|
|
267
|
+
<>
|
|
268
|
+
{entries.length === 0 ? (
|
|
269
|
+
<ActionRow
|
|
270
|
+
title="No MCP servers"
|
|
271
|
+
subtitle={
|
|
272
|
+
<>
|
|
273
|
+
Add them under <code className="font-mono">mcp</code> in opencode.json, or in{" "}
|
|
274
|
+
<code className="font-mono">.mcp.json</code> for Claude Code, then reload.
|
|
275
|
+
</>
|
|
276
|
+
}
|
|
277
|
+
/>
|
|
278
|
+
) : (
|
|
279
|
+
entries.map((entry) => <McpRow key={entry.name} entry={entry} />)
|
|
280
|
+
)}
|
|
281
|
+
<PartialFailureRow failed={state.data.failed} />
|
|
282
|
+
</>
|
|
242
283
|
)}
|
|
243
|
-
</
|
|
284
|
+
</PrefsGroup>
|
|
285
|
+
);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Satu MCP server (AdwActionRow): ikon simbolik berwarna status, nama, lalu
|
|
290
|
+
* status per agent di subjudul — "logo Label" untuk tiap agent pemakai.
|
|
291
|
+
* Error tampil di bawahnya per agent.
|
|
292
|
+
*/
|
|
293
|
+
function McpRow({ entry }: { entry: McpServerEntry }) {
|
|
294
|
+
const errors = entry.agents
|
|
295
|
+
.map((a) => ({ agent: a, error: entry.byAgent[a]?.error ?? null }))
|
|
296
|
+
.filter((e): e is { agent: AgentType; error: string } => e.error !== null);
|
|
297
|
+
return (
|
|
298
|
+
<ActionRow
|
|
299
|
+
prefix={<PlugIcon className={cn("size-[18px]", MCP_ICON[entry.status])} aria-hidden />}
|
|
300
|
+
title={<span className="block truncate">{entry.name}</span>}
|
|
301
|
+
subtitle={
|
|
302
|
+
<span className="flex flex-col gap-1">
|
|
303
|
+
<span className="flex flex-wrap items-center gap-x-3 gap-y-0.5">
|
|
304
|
+
{entry.agents.map((a) => {
|
|
305
|
+
const status = entry.byAgent[a]?.status ?? "unknown";
|
|
306
|
+
const label = mcpAgentLabel(a, status);
|
|
307
|
+
return (
|
|
308
|
+
<span
|
|
309
|
+
key={a}
|
|
310
|
+
className={cn("inline-flex items-center gap-1.5", MCP_TEXT[status])}
|
|
311
|
+
title={
|
|
312
|
+
a === "claude-code" && status === "unknown"
|
|
313
|
+
? "Configured for Claude Code. Status appears once a Claude Code session runs in this project."
|
|
314
|
+
: `${AGENT_TYPE_LABELS[a]}: ${label}`
|
|
315
|
+
}
|
|
316
|
+
>
|
|
317
|
+
<AgentLogo agentType={a} className="size-3" />
|
|
318
|
+
<span className="sr-only">{AGENT_TYPE_LABELS[a]}:</span>
|
|
319
|
+
{label}
|
|
320
|
+
</span>
|
|
321
|
+
);
|
|
322
|
+
})}
|
|
323
|
+
</span>
|
|
324
|
+
{errors.map((e) => (
|
|
325
|
+
<span
|
|
326
|
+
key={e.agent}
|
|
327
|
+
className="line-clamp-2 font-mono text-[11.5px] leading-relaxed break-all text-destructive/90"
|
|
328
|
+
title={e.error}
|
|
329
|
+
>
|
|
330
|
+
{entry.agents.length > 1 && `${AGENT_TYPE_LABELS[e.agent]}: `}
|
|
331
|
+
{e.error}
|
|
332
|
+
</span>
|
|
333
|
+
))}
|
|
334
|
+
</span>
|
|
335
|
+
}
|
|
336
|
+
/>
|
|
244
337
|
);
|
|
245
338
|
}
|
|
246
339
|
|
|
@@ -248,15 +341,34 @@ function McpSection({ state, onReload }: { state: Load<McpServerInfo[]>; onReloa
|
|
|
248
341
|
|
|
249
342
|
type SkillFilter = "all" | SkillSource;
|
|
250
343
|
|
|
251
|
-
|
|
344
|
+
const SKILL_FILTER_LABEL: Record<SkillFilter, string> = {
|
|
345
|
+
all: "All",
|
|
346
|
+
project: "Project",
|
|
347
|
+
global: "Global",
|
|
348
|
+
builtin: "Built-in",
|
|
349
|
+
};
|
|
350
|
+
|
|
351
|
+
function SkillsGroup({
|
|
352
|
+
state,
|
|
353
|
+
projectId,
|
|
354
|
+
}: {
|
|
355
|
+
state: Load<AgentLists<SkillInfo>>;
|
|
356
|
+
projectId: string;
|
|
357
|
+
}) {
|
|
358
|
+
const { navigate } = useRouter();
|
|
252
359
|
const [query, setQuery] = useState("");
|
|
253
360
|
const [filter, setFilter] = useState<SkillFilter>("all");
|
|
254
361
|
const [expanded, setExpanded] = useState(false);
|
|
255
362
|
|
|
256
|
-
|
|
363
|
+
// Skill yang sama di kedua agent jadi satu baris dengan dua logo.
|
|
364
|
+
const all = useMemo(
|
|
365
|
+
() => (state.phase === "ready" ? mergeInstalledSkills(state.data.byAgent) : []),
|
|
366
|
+
[state],
|
|
367
|
+
);
|
|
257
368
|
// Urutan tampil: Project -> global -> bawaan (paling relevan dulu).
|
|
258
|
-
const
|
|
259
|
-
const
|
|
369
|
+
const groups = useMemo(() => groupSkills(all), [all]);
|
|
370
|
+
const ordered = useMemo(() => groups.flatMap((g) => g.skills), [groups]);
|
|
371
|
+
const sources = useMemo(() => groups.map((g) => g.source), [groups]);
|
|
260
372
|
const bySource = useMemo(
|
|
261
373
|
() => (filter === "all" ? ordered : ordered.filter((s) => s.source === filter)),
|
|
262
374
|
[ordered, filter],
|
|
@@ -266,183 +378,137 @@ function SkillsSection({ state }: { state: Load<SkillInfo[]> }) {
|
|
|
266
378
|
// Saat mencari, tampilkan semua hasil; selain itu batasi hingga "Show more".
|
|
267
379
|
const visible = searching || expanded ? filtered : filtered.slice(0, SKILL_PREVIEW);
|
|
268
380
|
const hidden = filtered.length - visible.length;
|
|
381
|
+
const browse = () => navigate(skillsPath(projectId));
|
|
382
|
+
|
|
383
|
+
const showSearch = all.length >= SKILL_SEARCH_THRESHOLD;
|
|
384
|
+
const showFilter = sources.length > 1;
|
|
385
|
+
const toolbar =
|
|
386
|
+
state.phase === "ready" && (showSearch || showFilter) ? (
|
|
387
|
+
<div className="flex flex-col gap-2">
|
|
388
|
+
{showSearch && (
|
|
389
|
+
<SearchEntry
|
|
390
|
+
value={query}
|
|
391
|
+
onValueChange={setQuery}
|
|
392
|
+
placeholder={`Search ${all.length} skills`}
|
|
393
|
+
aria-label="Search skills"
|
|
394
|
+
/>
|
|
395
|
+
)}
|
|
396
|
+
{showFilter && (
|
|
397
|
+
<AdwToggleGroup
|
|
398
|
+
label="Filter skills by source"
|
|
399
|
+
value={filter}
|
|
400
|
+
onChange={(f) => {
|
|
401
|
+
setFilter(f);
|
|
402
|
+
setExpanded(false);
|
|
403
|
+
}}
|
|
404
|
+
options={(["all", ...sources] as SkillFilter[]).map((f) => {
|
|
405
|
+
const count = f === "all" ? all.length : all.filter((s) => s.source === f).length;
|
|
406
|
+
return {
|
|
407
|
+
value: f,
|
|
408
|
+
label: SKILL_FILTER_LABEL[f],
|
|
409
|
+
title: `${SKILL_FILTER_LABEL[f]}: ${count}`,
|
|
410
|
+
ariaLabel: `${SKILL_FILTER_LABEL[f]}, ${count} skills`,
|
|
411
|
+
};
|
|
412
|
+
})}
|
|
413
|
+
className="[&_button]:text-[13px]"
|
|
414
|
+
/>
|
|
415
|
+
)}
|
|
416
|
+
</div>
|
|
417
|
+
) : undefined;
|
|
269
418
|
|
|
270
419
|
return (
|
|
271
|
-
<
|
|
272
|
-
id="skills"
|
|
420
|
+
<PrefsGroup
|
|
273
421
|
title="Skills"
|
|
274
|
-
|
|
275
|
-
meta={
|
|
422
|
+
description={
|
|
276
423
|
state.phase === "ready"
|
|
277
424
|
? all.length === 0
|
|
278
|
-
? "None
|
|
279
|
-
: `${all.length} available`
|
|
280
|
-
:
|
|
425
|
+
? "None installed"
|
|
426
|
+
: `${all.length} available${filter !== "all" || searching ? ` · ${filtered.length} shown` : ""}`
|
|
427
|
+
: "Loading…"
|
|
428
|
+
}
|
|
429
|
+
suffix={
|
|
430
|
+
<FlatIconButton label="Browse and install skills" onClick={browse}>
|
|
431
|
+
<PlusIcon />
|
|
432
|
+
</FlatIconButton>
|
|
281
433
|
}
|
|
434
|
+
toolbar={toolbar}
|
|
282
435
|
>
|
|
283
436
|
{state.phase === "loading" ? (
|
|
284
|
-
<
|
|
437
|
+
<SkeletonRows rows={3} />
|
|
285
438
|
) : state.phase === "error" ? (
|
|
286
|
-
<
|
|
287
|
-
) : all.length === 0 ? (
|
|
288
|
-
<PanelHint>
|
|
289
|
-
No skills found. Add a <code className="font-mono">SKILL.md</code> under{" "}
|
|
290
|
-
<code className="font-mono">.opencode/skills/</code>.
|
|
291
|
-
</PanelHint>
|
|
439
|
+
<ErrorRow message={state.message} />
|
|
292
440
|
) : (
|
|
293
|
-
|
|
294
|
-
{
|
|
295
|
-
<
|
|
296
|
-
{
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
onChange={(e) => setQuery(e.target.value)}
|
|
306
|
-
onKeyDown={(e) => e.key === "Escape" && setQuery("")}
|
|
307
|
-
placeholder={`Search ${all.length} skills`}
|
|
308
|
-
aria-label="Search skills"
|
|
309
|
-
className="h-9 w-full rounded-lg border bg-background pr-8 pl-8.5 text-sm outline-none transition-shadow placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 [&::-webkit-search-cancel-button]:hidden"
|
|
310
|
-
/>
|
|
311
|
-
{query && (
|
|
312
|
-
<button
|
|
313
|
-
type="button"
|
|
314
|
-
onClick={() => setQuery("")}
|
|
315
|
-
aria-label="Clear search"
|
|
316
|
-
className="absolute top-1/2 right-2 flex size-5 -translate-y-1/2 items-center justify-center rounded text-muted-foreground hover:text-foreground"
|
|
317
|
-
>
|
|
318
|
-
<XIcon className="size-3.5" />
|
|
319
|
-
</button>
|
|
320
|
-
)}
|
|
321
|
-
</div>
|
|
322
|
-
)}
|
|
323
|
-
|
|
324
|
-
{sources.length > 1 && (
|
|
325
|
-
<fieldset
|
|
326
|
-
aria-label="Filter skills by source"
|
|
327
|
-
className="m-0 flex flex-wrap gap-1 border-0 p-0"
|
|
328
|
-
>
|
|
329
|
-
{(["all", ...sources] as SkillFilter[]).map((f) => {
|
|
330
|
-
const count =
|
|
331
|
-
f === "all" ? all.length : all.filter((s) => s.source === f).length;
|
|
332
|
-
const active = filter === f;
|
|
333
|
-
return (
|
|
334
|
-
<button
|
|
335
|
-
key={f}
|
|
336
|
-
type="button"
|
|
337
|
-
aria-pressed={active}
|
|
338
|
-
onClick={() => {
|
|
339
|
-
setFilter(f);
|
|
340
|
-
setExpanded(false);
|
|
341
|
-
}}
|
|
342
|
-
className={cn(
|
|
343
|
-
"inline-flex h-7 items-center gap-1.5 rounded-full px-2.5 text-xs font-medium transition-colors focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none",
|
|
344
|
-
active
|
|
345
|
-
? "bg-foreground text-background"
|
|
346
|
-
: "text-muted-foreground hover:bg-muted hover:text-foreground",
|
|
347
|
-
)}
|
|
348
|
-
>
|
|
349
|
-
{f === "all" ? "All" : SKILL_SOURCE_LABEL[f]}
|
|
350
|
-
<span className={cn("tabular-nums", active ? "opacity-70" : "opacity-60")}>
|
|
351
|
-
{count}
|
|
352
|
-
</span>
|
|
353
|
-
</button>
|
|
354
|
-
);
|
|
355
|
-
})}
|
|
356
|
-
</fieldset>
|
|
357
|
-
)}
|
|
358
|
-
</PanelBody>
|
|
359
|
-
)}
|
|
360
|
-
|
|
361
|
-
{visible.length === 0 ? (
|
|
362
|
-
<PanelHint>No skills match "{query.trim()}".</PanelHint>
|
|
441
|
+
<>
|
|
442
|
+
{all.length === 0 ? (
|
|
443
|
+
<ActionRow
|
|
444
|
+
prefix={<SparklesIcon className="size-[18px] text-muted-foreground" aria-hidden />}
|
|
445
|
+
title="Browse skills"
|
|
446
|
+
subtitle="Install one from skills.sh for OpenCode, Claude Code, or both"
|
|
447
|
+
onActivate={browse}
|
|
448
|
+
/>
|
|
449
|
+
) : visible.length === 0 ? (
|
|
450
|
+
<li className="px-4 py-4 text-center text-[13px] text-muted-foreground">
|
|
451
|
+
No skills match “{query.trim()}”.
|
|
452
|
+
</li>
|
|
363
453
|
) : (
|
|
364
|
-
|
|
365
|
-
{
|
|
366
|
-
|
|
367
|
-
))}
|
|
368
|
-
{!searching && filtered.length > SKILL_PREVIEW && (
|
|
369
|
-
<li>
|
|
370
|
-
<button
|
|
371
|
-
type="button"
|
|
372
|
-
onClick={() => setExpanded((v) => !v)}
|
|
373
|
-
aria-expanded={expanded}
|
|
374
|
-
className="flex w-full items-center justify-center gap-1.5 px-3.5 py-2.5 text-xs font-medium text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground focus-visible:bg-muted/50 focus-visible:outline-none"
|
|
375
|
-
>
|
|
376
|
-
{expanded ? "Show less" : `Show ${hidden} more`}
|
|
377
|
-
<ChevronDownIcon
|
|
378
|
-
className={cn("size-3.5 transition-transform", expanded && "rotate-180")}
|
|
379
|
-
aria-hidden
|
|
380
|
-
/>
|
|
381
|
-
</button>
|
|
382
|
-
</li>
|
|
383
|
-
)}
|
|
384
|
-
</PanelList>
|
|
454
|
+
visible.map((s) => (
|
|
455
|
+
<SkillRow key={`${s.source}:${s.name}`} skill={s} showSource={filter === "all"} />
|
|
456
|
+
))
|
|
385
457
|
)}
|
|
386
|
-
|
|
458
|
+
{!searching && filtered.length > SKILL_PREVIEW && (
|
|
459
|
+
<li>
|
|
460
|
+
<button
|
|
461
|
+
type="button"
|
|
462
|
+
onClick={() => setExpanded((v) => !v)}
|
|
463
|
+
aria-expanded={expanded}
|
|
464
|
+
className="flex min-h-12 w-full items-center justify-center px-4 text-[15px] transition-colors outline-none hover:bg-muted/60 focus-visible:bg-muted/60 focus-visible:ring-[3px] focus-visible:ring-ring/40 focus-visible:ring-inset active:bg-muted"
|
|
465
|
+
>
|
|
466
|
+
{expanded ? "Show less" : `Show ${hidden} more`}
|
|
467
|
+
</button>
|
|
468
|
+
</li>
|
|
469
|
+
)}
|
|
470
|
+
<PartialFailureRow failed={state.data.failed} />
|
|
471
|
+
</>
|
|
387
472
|
)}
|
|
388
|
-
</
|
|
473
|
+
</PrefsGroup>
|
|
389
474
|
);
|
|
390
475
|
}
|
|
391
476
|
|
|
392
477
|
/**
|
|
393
|
-
* Satu skill:
|
|
394
|
-
*
|
|
478
|
+
* Satu skill (AdwExpanderRow): nama (+ label asal), deskripsi 1 baris, logo
|
|
479
|
+
* agent pemakai. Dibuka -> deskripsi lengkap & path SKILL.md.
|
|
395
480
|
*/
|
|
396
|
-
function SkillRow({ skill, showSource }: { skill:
|
|
397
|
-
const
|
|
398
|
-
|
|
481
|
+
function SkillRow({ skill, showSource }: { skill: InstalledSkill; showSource: boolean }) {
|
|
482
|
+
const details =
|
|
483
|
+
skill.description || skill.location ? (
|
|
484
|
+
<div className="flex flex-col gap-2">
|
|
485
|
+
{skill.description && (
|
|
486
|
+
<p className="text-[13px] leading-relaxed whitespace-pre-line">{skill.description}</p>
|
|
487
|
+
)}
|
|
488
|
+
{skill.location && (
|
|
489
|
+
<p
|
|
490
|
+
className="truncate font-mono text-[11.5px] text-muted-foreground"
|
|
491
|
+
title={skill.location}
|
|
492
|
+
>
|
|
493
|
+
{skill.location}
|
|
494
|
+
</p>
|
|
495
|
+
)}
|
|
496
|
+
</div>
|
|
497
|
+
) : undefined;
|
|
399
498
|
return (
|
|
400
|
-
<
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
className="flex w-full items-start gap-3 px-3.5 py-3 text-left transition-colors hover:bg-muted/50 focus-visible:bg-muted/50 focus-visible:outline-none disabled:cursor-default disabled:hover:bg-transparent"
|
|
407
|
-
>
|
|
408
|
-
<RowIcon className={cn(skill.source === "project" && "bg-primary/10 text-primary")}>
|
|
409
|
-
<SparklesIcon />
|
|
410
|
-
</RowIcon>
|
|
411
|
-
<span className="flex min-w-0 flex-1 flex-col gap-0.5 pt-0.5">
|
|
412
|
-
<span className="flex min-w-0 items-center gap-1.5">
|
|
413
|
-
<span className="truncate text-sm font-medium leading-5">{skill.name}</span>
|
|
414
|
-
{showSource && skill.source !== "global" && (
|
|
415
|
-
<span className="shrink-0 rounded bg-muted px-1.5 text-[10px] font-medium leading-4 text-muted-foreground">
|
|
416
|
-
{skill.source === "project" ? "Project" : "Built-in"}
|
|
417
|
-
</span>
|
|
418
|
-
)}
|
|
419
|
-
</span>
|
|
420
|
-
{skill.description && (
|
|
421
|
-
<span
|
|
422
|
-
className={cn(
|
|
423
|
-
"text-xs leading-relaxed text-muted-foreground",
|
|
424
|
-
open ? "whitespace-pre-line" : "line-clamp-1",
|
|
425
|
-
)}
|
|
426
|
-
>
|
|
427
|
-
{skill.description}
|
|
428
|
-
</span>
|
|
429
|
-
)}
|
|
430
|
-
{open && skill.location && (
|
|
431
|
-
<span className="mt-1 truncate font-mono text-[11px] text-muted-foreground/80">
|
|
432
|
-
{skill.location}
|
|
433
|
-
</span>
|
|
499
|
+
<ExpanderRow
|
|
500
|
+
title={
|
|
501
|
+
<>
|
|
502
|
+
<span className="truncate">{skill.name}</span>
|
|
503
|
+
{showSource && skill.source !== "global" && (
|
|
504
|
+
<RowBadge>{skill.source === "project" ? "Project" : "Built-in"}</RowBadge>
|
|
434
505
|
)}
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
aria-hidden
|
|
443
|
-
/>
|
|
444
|
-
)}
|
|
445
|
-
</button>
|
|
446
|
-
</li>
|
|
506
|
+
</>
|
|
507
|
+
}
|
|
508
|
+
subtitle={skill.description ?? undefined}
|
|
509
|
+
suffix={<AgentBadges agents={skill.agents} size="sm" />}
|
|
510
|
+
>
|
|
511
|
+
{details}
|
|
512
|
+
</ExpanderRow>
|
|
447
513
|
);
|
|
448
514
|
}
|