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.
Files changed (83) hide show
  1. package/README.md +2 -0
  2. package/package.json +2 -1
  3. package/src/cli/__tests__/daemon.test.ts +84 -0
  4. package/src/cli/__tests__/dashboard.test.ts +170 -8
  5. package/src/cli/__tests__/keep-awake.test.ts +44 -0
  6. package/src/cli/__tests__/port.test.ts +86 -0
  7. package/src/cli/args.ts +69 -6
  8. package/src/cli/attach.ts +154 -0
  9. package/src/cli/commands.ts +174 -21
  10. package/src/cli/daemon.ts +275 -0
  11. package/src/cli/dashboard.tsx +880 -159
  12. package/src/cli/index.ts +10 -0
  13. package/src/cli/ink-react.ts +26 -0
  14. package/src/cli/keep-awake.ts +148 -0
  15. package/src/cli/port.ts +92 -0
  16. package/src/cli/theme.ts +6 -6
  17. package/src/components/layout/AppShell.tsx +8 -3
  18. package/src/components/projects/HomeChat.tsx +85 -48
  19. package/src/components/projects/HomeRecentSessions.tsx +90 -0
  20. package/src/components/projects/ProjectInstructions.tsx +27 -38
  21. package/src/components/projects/ProjectSidePanel.tsx +382 -316
  22. package/src/components/sessions/AgentLogo.tsx +42 -0
  23. package/src/components/sessions/AgentPicker.tsx +13 -4
  24. package/src/components/sessions/ConfirmSessionDeleteDialog.tsx +27 -7
  25. package/src/components/sessions/ContextPanel.tsx +159 -59
  26. package/src/components/sessions/ModelPicker.tsx +65 -27
  27. package/src/components/sessions/ModelSearchList.tsx +137 -57
  28. package/src/components/sessions/NewSessionDialog.tsx +100 -117
  29. package/src/components/sessions/SessionComposer.tsx +2 -1
  30. package/src/components/sessions/SessionHeader.tsx +1 -0
  31. package/src/components/sessions/SessionList.tsx +173 -66
  32. package/src/components/sessions/SessionRow.tsx +176 -76
  33. package/src/components/sessions/SessionView.tsx +17 -13
  34. package/src/components/sessions/__tests__/ContextPanel.test.tsx +36 -0
  35. package/src/components/settings/LhrRemoteAccess.tsx +20 -25
  36. package/src/components/settings/RemoteAccessGroup.tsx +242 -267
  37. package/src/components/settings/prefs.tsx +69 -1
  38. package/src/components/skills/InstallNotifications.tsx +4 -3
  39. package/src/components/skills/InstallSkillDialog.tsx +256 -99
  40. package/src/components/skills/SkillCards.tsx +52 -8
  41. package/src/components/ui/adw-dialog.tsx +114 -0
  42. package/src/components/ui/adw-toggle-group.tsx +75 -0
  43. package/src/components/ui/search-entry.tsx +70 -0
  44. package/src/config.ts +32 -3
  45. package/src/hooks/useAgentPicker.ts +6 -2
  46. package/src/hooks/useSessionList.ts +95 -2
  47. package/src/lib/__tests__/project-extensions.test.ts +32 -1
  48. package/src/lib/__tests__/project-overview.test.ts +15 -0
  49. package/src/lib/__tests__/routes.test.ts +2 -2
  50. package/src/lib/__tests__/session-selection.test.ts +41 -0
  51. package/src/lib/__tests__/skill-installs.test.ts +1 -0
  52. package/src/lib/__tests__/skills-catalog.test.ts +36 -8
  53. package/src/lib/agent-types.ts +49 -0
  54. package/src/lib/project-extensions.ts +66 -4
  55. package/src/lib/project-overview.ts +14 -8
  56. package/src/lib/routes.ts +13 -4
  57. package/src/lib/session-selection.ts +40 -0
  58. package/src/lib/skill-installs.ts +3 -1
  59. package/src/lib/skills-catalog.ts +56 -17
  60. package/src/pages/projects/ProjectDetailPage.tsx +13 -12
  61. package/src/pages/settings/SettingsPage.tsx +212 -145
  62. package/src/pages/settings/__tests__/SettingsPage.test.ts +11 -4
  63. package/src/pages/skills/SkillsPage.tsx +89 -45
  64. package/src/server/__tests__/app.test.ts +15 -2
  65. package/src/server/app.ts +13 -1
  66. package/src/server/routes/projects.routes.ts +14 -4
  67. package/src/server/routes/sessions.routes.ts +1 -1
  68. package/src/server/routes/skills.routes.ts +24 -5
  69. package/src/server/services/__tests__/claude-code.test.ts +609 -0
  70. package/src/server/services/__tests__/claude-mcp.test.ts +90 -0
  71. package/src/server/services/__tests__/claude-skills.test.ts +63 -0
  72. package/src/server/services/__tests__/session-manager.test.ts +40 -0
  73. package/src/server/services/__tests__/skill-install-jobs.test.ts +22 -3
  74. package/src/server/services/__tests__/skills-registry.test.ts +6 -0
  75. package/src/server/services/claude-code-client.ts +1093 -0
  76. package/src/server/services/claude-code-translate.ts +370 -0
  77. package/src/server/services/claude-mcp.ts +122 -0
  78. package/src/server/services/claude-skills.ts +102 -0
  79. package/src/server/services/session-manager.ts +117 -52
  80. package/src/server/services/skill-install-jobs.ts +21 -8
  81. package/src/server/services/skills-registry.ts +18 -6
  82. package/src/types/message.ts +1 -1
  83. package/src/components/projects/PanelSection.tsx +0 -102
@@ -6,10 +6,12 @@
6
6
  * diklik (tombol / tautan) dengan chevron seperti GNOME.
7
7
  * - `EntryRow` label kecil di atas input sebaris; tombol ✓ muncul saat
8
8
  * nilai berubah (seperti AdwEntryRow "apply button").
9
+ * - `ExpanderRow` baris yang membuka isi tambahan di bawahnya (seperti
10
+ * AdwExpanderRow): chevron berputar, isi berlatar redup.
9
11
  * - `SwitchRow` tidak dibutuhkan saat ini; `ComboRow` diwakili ActionRow +
10
12
  * Select di suffix.
11
13
  */
12
- import { CheckIcon, ChevronRightIcon } from "lucide-react";
14
+ import { CheckIcon, ChevronDownIcon, ChevronRightIcon } from "lucide-react";
13
15
  import { type ReactNode, useId, useState } from "react";
14
16
  import { Spinner } from "@/components/ui/spinner";
15
17
  import { cn } from "@/lib/utils";
@@ -19,6 +21,7 @@ export function PrefsGroup({
19
21
  title,
20
22
  description,
21
23
  suffix,
24
+ toolbar,
22
25
  children,
23
26
  }: {
24
27
  id?: string;
@@ -26,6 +29,8 @@ export function PrefsGroup({
26
29
  description?: ReactNode;
27
30
  /** Aksi kecil di kanan judul grup (mis. tombol). */
28
31
  suffix?: ReactNode;
32
+ /** Kontrol di antara judul & boxed list (mis. kolom cari, filter). */
33
+ toolbar?: ReactNode;
29
34
  children: ReactNode;
30
35
  }) {
31
36
  const headingId = useId();
@@ -50,6 +55,7 @@ export function PrefsGroup({
50
55
  {description && <p className="text-[13px] text-muted-foreground">{description}</p>}
51
56
  </div>
52
57
  )}
58
+ {toolbar}
53
59
  <div className="overflow-hidden rounded-xl border bg-card shadow-xs">
54
60
  <ul className="flex flex-col divide-y">{children}</ul>
55
61
  </div>
@@ -242,6 +248,68 @@ export function EntryRow({
242
248
  );
243
249
  }
244
250
 
251
+ /**
252
+ * Baris ala AdwExpanderRow: judul + subjudul (1 baris), suffix, chevron;
253
+ * klik membuka `children` di bawahnya (latar redup, dipisah garis). Tanpa
254
+ * `children` baris tampil biasa (tidak bisa dibuka).
255
+ */
256
+ export function ExpanderRow({
257
+ title,
258
+ subtitle,
259
+ prefix,
260
+ suffix,
261
+ children,
262
+ className,
263
+ }: {
264
+ title: ReactNode;
265
+ subtitle?: ReactNode;
266
+ prefix?: ReactNode;
267
+ suffix?: ReactNode;
268
+ children?: ReactNode;
269
+ className?: string;
270
+ }) {
271
+ const [open, setOpen] = useState(false);
272
+ const contentId = useId();
273
+ const expandable = children !== undefined && children !== null && children !== false;
274
+ return (
275
+ <li className={className}>
276
+ <button
277
+ type="button"
278
+ onClick={() => expandable && setOpen((v) => !v)}
279
+ aria-expanded={expandable ? open : undefined}
280
+ aria-controls={expandable ? contentId : undefined}
281
+ disabled={!expandable}
282
+ className="flex min-h-14 w-full items-center gap-3 px-4 py-2.5 text-left 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 disabled:cursor-default disabled:hover:bg-transparent"
283
+ >
284
+ {prefix && <span className="flex shrink-0 items-center">{prefix}</span>}
285
+ <span className="flex min-w-0 flex-1 flex-col gap-0.5 py-0.5">
286
+ <span className="flex min-w-0 items-center gap-2 text-[15px] leading-5">{title}</span>
287
+ {subtitle && (
288
+ <span className="truncate text-[13px] leading-snug text-muted-foreground">
289
+ {subtitle}
290
+ </span>
291
+ )}
292
+ </span>
293
+ {suffix && <span className="flex shrink-0 items-center gap-2">{suffix}</span>}
294
+ {expandable && (
295
+ <ChevronDownIcon
296
+ className={cn(
297
+ "size-4 shrink-0 text-muted-foreground transition-transform duration-200",
298
+ open && "rotate-180",
299
+ )}
300
+ aria-hidden
301
+ />
302
+ )}
303
+ </button>
304
+ {expandable && open && (
305
+ <div id={contentId} className="border-t bg-muted/30 px-4 py-3">
306
+ {children}
307
+ </div>
308
+ )}
309
+ </li>
310
+ );
311
+ }
312
+
245
313
  /** Pil status kecil untuk suffix baris (mis. "On" / "Off"). */
246
314
  export function RowBadge({
247
315
  tone = "muted",
@@ -24,6 +24,7 @@ import {
24
24
  } from "lucide-react";
25
25
  import { useEffect, useRef, useState } from "react";
26
26
  import { Spinner } from "@/components/ui/spinner";
27
+ import { AGENT_TYPE_LABELS } from "@/lib/agent-types";
27
28
  import { apiErrorMessage } from "@/lib/api";
28
29
  import {
29
30
  cancelInstall,
@@ -123,9 +124,9 @@ function InstallCard({ entry }: { entry: InstallEntry }) {
123
124
  ? apiErrorMessage(job.error ?? "SKILL_INSTALL_FAILED")
124
125
  : awaitingActivation
125
126
  ? `Added to ${job.projectName}. Activates automatically when the running chat finishes.`
126
- : `${running ? "into" : "Added to"} ${job.projectName}${
127
- job.status === "succeeded" ? " · active" : ""
128
- }`;
127
+ : `${running ? "into" : "Added to"} ${job.projectName} · ${(job.agentTypes ?? ["opencode"])
128
+ .map((a) => AGENT_TYPE_LABELS[a])
129
+ .join(" + ")}${job.status === "succeeded" ? " · active" : ""}`;
129
130
 
130
131
  return (
131
132
  <article
@@ -1,21 +1,30 @@
1
1
  /**
2
2
  * Dialog konfirmasi instalasi skill dari skills.sh.
3
3
  *
4
- * Menampilkan sumber, tautan ke halaman skill, dan hasil audit keamanan
5
- * (`GET /api/skills/audit`) SEBELUM user menekan Install — skill berjalan
6
- * dengan izin penuh agent, jadi keputusan dibuat dengan informasi lengkap.
7
- * Skill berstatus `fail` tetap boleh dipasang tapi tombolnya destruktif.
4
+ * Susunan (atas -> bawah), dari yang paling sering diputuskan:
5
+ * 1. Identitas skill: avatar owner, nama, `source · installs · skills.sh ↗`.
6
+ * 2. Agent tujuan: dua tile berdampingan (OpenCode / Claude Code, bisa
7
+ * keduanya). Agent yang belum memuat skill ini terpilih secara default;
8
+ * yang sudah memuatnya ditandai "Installed" (memilihnya = pasang ulang).
9
+ * 3. Audit keamanan (`GET /api/skills/audit`) dalam satu baris ringkas;
10
+ * rincian per partner dilipat dan otomatis terbuka bila ada temuan
11
+ * (`warn`/`fail`). Skill `fail` tetap boleh dipasang, tombolnya destruktif.
8
12
  *
9
- * Konfirmasi hanya MEMULAI job instalasi; dialog lalu ditutup dan progres +
10
- * output CLI tampil di notifikasi instalasi (`InstallNotifications`).
13
+ * Label tombol menyebut agent tujuan ("Install for Claude Code"). Konfirmasi
14
+ * hanya MEMULAI job instalasi; progres + output CLI tampil di notifikasi
15
+ * instalasi (`InstallNotifications`).
11
16
  */
12
17
  import {
18
+ CheckIcon,
19
+ ChevronDownIcon,
13
20
  ExternalLinkIcon,
14
21
  ShieldAlertIcon,
15
22
  ShieldCheckIcon,
16
23
  ShieldQuestionIcon,
17
24
  } from "lucide-react";
18
25
  import { useEffect, useState } from "react";
26
+ import { AgentLogo } from "@/components/sessions/AgentLogo";
27
+ import { SourceAvatar } from "@/components/skills/SkillCards";
19
28
  import {
20
29
  AlertDialog,
21
30
  AlertDialogAction,
@@ -26,12 +35,15 @@ import {
26
35
  AlertDialogHeader,
27
36
  AlertDialogTitle,
28
37
  } from "@/components/ui/alert-dialog";
38
+ import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible";
29
39
  import { Skeleton } from "@/components/ui/skeleton";
30
40
  import { Spinner } from "@/components/ui/spinner";
41
+ import { AGENT_TYPE_LABELS, AGENT_TYPES } from "@/lib/agent-types";
31
42
  import { ApiError, apiFetch } from "@/lib/api";
32
43
  import { AUDIT_BADGE_CLASS, AUDIT_LABEL, formatInstalls } from "@/lib/skills-catalog";
33
44
  import { cn } from "@/lib/utils";
34
45
  import type { RegistrySkill, SkillAudit } from "@/server/services/skills-registry";
46
+ import type { AgentType } from "@/types";
35
47
 
36
48
  type AuditState =
37
49
  | { phase: "loading" }
@@ -42,23 +54,52 @@ export interface InstallSkillDialogProps {
42
54
  /** Skill yang akan dipasang; `null` = dialog tertutup. */
43
55
  skill: RegistrySkill | null;
44
56
  projectName: string;
57
+ /** Agent yang sudah memuat skill ini dari folder Project. */
58
+ installedAgents: readonly AgentType[];
45
59
  /** Permintaan memulai instalasi sedang dikirim. */
46
60
  starting: boolean;
47
61
  /** Pesan bila server menolak memulai (mis. instalasi lain sedang berjalan). */
48
62
  startError: string | null;
49
63
  onOpenChange: (open: boolean) => void;
50
- onConfirm: (skill: RegistrySkill) => void;
64
+ onConfirm: (skill: RegistrySkill, agents: AgentType[]) => void;
65
+ }
66
+
67
+ /** Folder skill di Project per agent (petunjuk di tile). */
68
+ const AGENT_SKILLS_DIR: Record<AgentType, string> = {
69
+ opencode: ".agents/skills",
70
+ "claude-code": ".claude/skills",
71
+ };
72
+
73
+ /** Label tombol utama sesuai agent terpilih. */
74
+ function installLabel(agents: readonly AgentType[], flagged: boolean): string {
75
+ const verb = flagged ? "Install anyway" : "Install";
76
+ if (agents.length === 0) return verb;
77
+ if (agents.length === AGENT_TYPES.length) return `${verb} for both`;
78
+ return `${verb} for ${agents.map((a) => AGENT_TYPE_LABELS[a]).join(" + ")}`;
51
79
  }
52
80
 
53
81
  export function InstallSkillDialog({
54
82
  skill,
55
83
  projectName,
84
+ installedAgents,
56
85
  starting,
57
86
  startError,
58
87
  onOpenChange,
59
88
  onConfirm,
60
89
  }: InstallSkillDialogProps) {
61
90
  const [audit, setAudit] = useState<AuditState>({ phase: "loading" });
91
+ const [agents, setAgents] = useState<AgentType[]>([]);
92
+
93
+ // Dialog dibuka untuk skill baru -> pilih agent yang belum memuatnya.
94
+ // biome-ignore lint/correctness/useExhaustiveDependencies: hanya saat skill berganti
95
+ useEffect(() => {
96
+ if (!skill) return;
97
+ const missing = AGENT_TYPES.filter((a) => !installedAgents.includes(a));
98
+ setAgents(missing.length > 0 ? missing : [...AGENT_TYPES]);
99
+ }, [skill]);
100
+
101
+ const toggleAgent = (agent: AgentType, on: boolean) =>
102
+ setAgents((prev) => AGENT_TYPES.filter((a) => (a === agent ? on : prev.includes(a))));
62
103
 
63
104
  useEffect(() => {
64
105
  if (!skill) return;
@@ -92,45 +133,68 @@ export function InstallSkillDialog({
92
133
  if (!starting) onOpenChange(next);
93
134
  }}
94
135
  >
95
- <AlertDialogContent className="sm:max-w-md">
96
- <AlertDialogHeader>
97
- <AlertDialogTitle className="break-all">Install {skill?.name}?</AlertDialogTitle>
98
- <AlertDialogDescription>
99
- The skill will be added to <span className="font-medium">{projectName}</span> under{" "}
100
- <code className="font-mono text-xs">.agents/skills/</code>. Skills run with the agent's
101
- full permissions, so review them before use.
136
+ {/* Tinggi dibatasi agar dialog (audit terbuka) tetap muat di layar HP. */}
137
+ <AlertDialogContent className="max-h-[calc(100dvh-2rem)] gap-5 overflow-y-auto sm:max-w-md">
138
+ <AlertDialogHeader className="text-left">
139
+ <div className="flex min-w-0 items-center gap-3">
140
+ {skill && <SourceAvatar source={skill.source} />}
141
+ <div className="flex min-w-0 flex-col gap-0.5">
142
+ <AlertDialogTitle className="truncate" title={skill?.name}>
143
+ Install {skill?.name}
144
+ </AlertDialogTitle>
145
+ {skill && (
146
+ <p className="flex min-w-0 items-center gap-1.5 text-xs text-muted-foreground">
147
+ <span className="truncate font-mono" title={skill.source}>
148
+ {skill.source}
149
+ </span>
150
+ <span aria-hidden>·</span>
151
+ <span className="shrink-0 tabular-nums">
152
+ {formatInstalls(skill.installs)} installs
153
+ </span>
154
+ <span aria-hidden>·</span>
155
+ <a
156
+ href={skill.url}
157
+ target="_blank"
158
+ rel="noopener noreferrer"
159
+ className="inline-flex shrink-0 items-center gap-0.5 font-medium text-foreground underline-offset-4 hover:underline"
160
+ >
161
+ skills.sh
162
+ <ExternalLinkIcon className="size-3" aria-hidden />
163
+ <span className="sr-only">(opens in a new tab)</span>
164
+ </a>
165
+ </p>
166
+ )}
167
+ </div>
168
+ </div>
169
+ <AlertDialogDescription className="sr-only">
170
+ Choose which agents in {projectName} get this skill, then review its security audit.
102
171
  </AlertDialogDescription>
103
172
  </AlertDialogHeader>
104
173
 
105
174
  {skill && (
106
- <div className="flex flex-col gap-3 text-sm">
107
- <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5">
108
- <dt className="text-muted-foreground">Source</dt>
109
- <dd className="min-w-0 truncate font-mono text-xs leading-5">{skill.source}</dd>
110
- <dt className="text-muted-foreground">Installs</dt>
111
- <dd className="tabular-nums">{formatInstalls(skill.installs)}</dd>
112
- </dl>
113
- <a
114
- href={skill.url}
115
- target="_blank"
116
- rel="noopener noreferrer"
117
- className="inline-flex w-fit items-center gap-1 text-xs font-medium underline underline-offset-4"
118
- >
119
- View on skills.sh
120
- <ExternalLinkIcon className="size-3" aria-hidden />
121
- <span className="sr-only">(opens in a new tab)</span>
122
- </a>
123
-
124
- <section
125
- aria-label="Security audit"
126
- className="flex flex-col gap-2 rounded-xl border p-3"
127
- >
128
- <AuditSummary state={audit} />
129
- </section>
175
+ <div className="flex min-w-0 flex-col gap-4 text-sm">
176
+ <fieldset className="flex flex-col gap-2" disabled={starting}>
177
+ <legend className="mb-2 text-xs text-muted-foreground">
178
+ Add to <span className="font-medium text-foreground">{projectName}</span> for
179
+ </legend>
180
+ <div className="grid grid-cols-2 gap-2">
181
+ {AGENT_TYPES.map((a) => (
182
+ <AgentTile
183
+ key={a}
184
+ agent={a}
185
+ checked={agents.includes(a)}
186
+ installed={installedAgents.includes(a)}
187
+ onChange={(on) => toggleAgent(a, on)}
188
+ />
189
+ ))}
190
+ </div>
191
+ {agents.length === 0 && (
192
+ <p className="text-xs text-muted-foreground">Pick at least one agent.</p>
193
+ )}
194
+ </fieldset>
195
+
196
+ <AuditPanel state={audit} />
130
197
 
131
- <p className="text-xs text-muted-foreground">
132
- Installation runs in the background. You can follow the output in the notification.
133
- </p>
134
198
  {startError && (
135
199
  <p role="alert" className="text-xs text-destructive">
136
200
  {startError}
@@ -139,64 +203,148 @@ export function InstallSkillDialog({
139
203
  </div>
140
204
  )}
141
205
 
142
- <AlertDialogFooter>
143
- <AlertDialogCancel disabled={starting}>Cancel</AlertDialogCancel>
144
- <AlertDialogAction
145
- variant={flagged ? "destructive" : "default"}
146
- disabled={starting || !skill}
147
- onClick={(e) => {
148
- // Dialog tetap terbuka selama instalasi berjalan.
149
- e.preventDefault();
150
- if (skill) onConfirm(skill);
151
- }}
152
- >
153
- {starting ? (
154
- <>
155
- <Spinner data-icon="inline-start" />
156
- Starting…
157
- </>
158
- ) : flagged ? (
159
- "Install anyway"
160
- ) : (
161
- "Install"
162
- )}
163
- </AlertDialogAction>
164
- </AlertDialogFooter>
206
+ <div className="flex flex-col gap-3">
207
+ <p className="text-xs text-muted-foreground">
208
+ Skills run with the agent's full permissions. Installation continues in the background —
209
+ follow it in the notification.
210
+ </p>
211
+ <AlertDialogFooter>
212
+ <AlertDialogCancel disabled={starting}>Cancel</AlertDialogCancel>
213
+ <AlertDialogAction
214
+ variant={flagged ? "destructive" : "default"}
215
+ disabled={starting || !skill || agents.length === 0}
216
+ onClick={(e) => {
217
+ // Dialog tetap terbuka sampai job berhasil dibuat.
218
+ e.preventDefault();
219
+ if (skill && agents.length > 0) onConfirm(skill, agents);
220
+ }}
221
+ >
222
+ {starting ? (
223
+ <>
224
+ <Spinner data-icon="inline-start" />
225
+ Starting…
226
+ </>
227
+ ) : (
228
+ installLabel(agents, flagged)
229
+ )}
230
+ </AlertDialogAction>
231
+ </AlertDialogFooter>
232
+ </div>
165
233
  </AlertDialogContent>
166
234
  </AlertDialog>
167
235
  );
168
236
  }
169
237
 
170
- function AuditSummary({ state }: { state: AuditState }) {
238
+ /**
239
+ * Tile agent yang bisa dipilih: logo, nama, folder tujuan, indikator centang
240
+ * di pojok. Checkbox asli disembunyikan (`sr-only`) tapi tetap jadi kontrol
241
+ * yang dibaca screen reader & menerima fokus keyboard (Spasi).
242
+ */
243
+ function AgentTile({
244
+ agent,
245
+ checked,
246
+ installed,
247
+ onChange,
248
+ }: {
249
+ agent: AgentType;
250
+ checked: boolean;
251
+ installed: boolean;
252
+ onChange: (checked: boolean) => void;
253
+ }) {
254
+ return (
255
+ <label
256
+ className={cn(
257
+ "relative flex cursor-pointer flex-col gap-2.5 rounded-xl border p-3 transition-colors select-none",
258
+ "has-focus-visible:ring-[3px] has-focus-visible:ring-ring/50 has-disabled:cursor-not-allowed has-disabled:opacity-60",
259
+ checked ? "border-primary bg-primary/5" : "hover:border-foreground/20 hover:bg-accent/30",
260
+ )}
261
+ >
262
+ <input
263
+ type="checkbox"
264
+ checked={checked}
265
+ onChange={(e) => onChange(e.target.checked)}
266
+ className="peer sr-only"
267
+ />
268
+ <span className="flex items-start justify-between">
269
+ <span className="flex size-8 items-center justify-center rounded-lg bg-muted text-foreground">
270
+ <AgentLogo agentType={agent} className="size-4" />
271
+ </span>
272
+ <span
273
+ aria-hidden
274
+ className={cn(
275
+ "flex size-5 items-center justify-center rounded-full border transition-colors",
276
+ checked ? "border-primary bg-primary text-primary-foreground" : "bg-background",
277
+ )}
278
+ >
279
+ {checked && <CheckIcon className="size-3" strokeWidth={3} />}
280
+ </span>
281
+ </span>
282
+ <span className="flex min-w-0 flex-col gap-0.5">
283
+ <span className="text-sm font-medium leading-5">{AGENT_TYPE_LABELS[agent]}</span>
284
+ <span className="truncate font-mono text-[11px] text-muted-foreground">
285
+ {installed ? (checked ? "Installed · reinstall" : "Installed") : AGENT_SKILLS_DIR[agent]}
286
+ </span>
287
+ </span>
288
+ </label>
289
+ );
290
+ }
291
+
292
+ /**
293
+ * Audit keamanan sebagai satu baris ringkas (ikon, status, jumlah partner
294
+ * yang lolos). Rincian per partner bisa dibuka; terbuka otomatis bila ada
295
+ * temuan agar tidak terlewat.
296
+ */
297
+ function AuditPanel({ state }: { state: AuditState }) {
298
+ const shell = "rounded-xl border bg-muted/30";
299
+
171
300
  if (state.phase === "loading") {
172
301
  return (
173
- <div role="status" className="flex flex-col gap-2" aria-busy="true">
302
+ <div role="status" aria-busy="true" className={cn(shell, "flex items-center gap-3 p-3")}>
174
303
  <span className="sr-only">Loading security audit…</span>
175
- <Skeleton className="h-4 w-1/2" />
176
- <Skeleton className="h-3 w-3/4" />
304
+ <Skeleton className="size-4 rounded-full" />
305
+ <Skeleton className="h-4 w-40" />
177
306
  </div>
178
307
  );
179
308
  }
180
309
  if (state.phase === "error") {
181
- return <p className="text-xs text-muted-foreground">{state.message}</p>;
310
+ return (
311
+ <p className={cn(shell, "flex items-center gap-2 p-3 text-xs text-muted-foreground")}>
312
+ <ShieldQuestionIcon className="size-4 shrink-0" aria-hidden />
313
+ {state.message}
314
+ </p>
315
+ );
182
316
  }
183
317
 
184
318
  const { audits, overall } = state.audit;
185
319
  if (audits === null || overall === null) {
186
320
  return (
187
- <p className="flex items-center gap-2 text-xs text-muted-foreground">
321
+ <p className={cn(shell, "flex items-start gap-2 p-3 text-xs text-muted-foreground")}>
188
322
  <ShieldQuestionIcon className="size-4 shrink-0" aria-hidden />
189
- No security audit is available for this skill yet.
323
+ <span>
324
+ <span className="font-medium text-foreground">Not audited yet.</span> Review the skill on
325
+ skills.sh before installing.
326
+ </span>
190
327
  </p>
191
328
  );
192
329
  }
193
330
 
331
+ const passed = audits.filter((a) => a.status === "pass").length;
194
332
  const Icon = overall === "pass" ? ShieldCheckIcon : ShieldAlertIcon;
195
333
  return (
196
- <>
197
- <p className="flex items-center gap-2 text-xs font-medium">
198
- <Icon className="size-4 shrink-0" aria-hidden />
199
- Security audit
334
+ <Collapsible defaultOpen={overall !== "pass"} className={shell}>
335
+ <CollapsibleTrigger className="group flex w-full items-center gap-2 rounded-xl p-3 text-left text-xs outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50">
336
+ <Icon
337
+ className={cn(
338
+ "size-4 shrink-0",
339
+ overall === "pass"
340
+ ? "text-emerald-600 dark:text-emerald-400"
341
+ : overall === "warn"
342
+ ? "text-amber-600 dark:text-amber-400"
343
+ : "text-destructive",
344
+ )}
345
+ aria-hidden
346
+ />
347
+ <span className="font-medium">Security audit</span>
200
348
  <span
201
349
  className={cn(
202
350
  "rounded px-1.5 text-[11px] leading-5 font-medium",
@@ -205,29 +353,38 @@ function AuditSummary({ state }: { state: AuditState }) {
205
353
  >
206
354
  {AUDIT_LABEL[overall]}
207
355
  </span>
208
- </p>
209
- <ul className="flex flex-col gap-1.5">
210
- {audits.map((a) => (
211
- <li key={a.provider} className="flex items-start justify-between gap-3 text-xs">
212
- <span className="min-w-0">
213
- <span className="font-medium">{a.provider}</span>
214
- {a.summary && (
215
- <span className="line-clamp-2 text-muted-foreground" title={a.summary}>
216
- {a.summary}
217
- </span>
218
- )}
219
- </span>
220
- <span
221
- className={cn(
222
- "shrink-0 rounded px-1.5 text-[11px] leading-5 font-medium",
223
- AUDIT_BADGE_CLASS[a.status],
224
- )}
225
- >
226
- {AUDIT_LABEL[a.status]}
227
- </span>
228
- </li>
229
- ))}
230
- </ul>
231
- </>
356
+ <span className="ml-auto text-muted-foreground tabular-nums">
357
+ {passed}/{audits.length} passed
358
+ </span>
359
+ <ChevronDownIcon
360
+ className="size-4 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180"
361
+ aria-hidden
362
+ />
363
+ </CollapsibleTrigger>
364
+ <CollapsibleContent>
365
+ <ul className="flex flex-col gap-2 border-t px-3 py-3">
366
+ {audits.map((a) => (
367
+ <li key={a.provider} className="flex items-start justify-between gap-3 text-xs">
368
+ <span className="flex min-w-0 flex-col">
369
+ <span className="font-medium">{a.provider}</span>
370
+ {a.summary && (
371
+ <span className="line-clamp-2 text-muted-foreground" title={a.summary}>
372
+ {a.summary}
373
+ </span>
374
+ )}
375
+ </span>
376
+ <span
377
+ className={cn(
378
+ "shrink-0 rounded px-1.5 text-[11px] leading-5 font-medium",
379
+ AUDIT_BADGE_CLASS[a.status],
380
+ )}
381
+ >
382
+ {AUDIT_LABEL[a.status]}
383
+ </span>
384
+ </li>
385
+ ))}
386
+ </ul>
387
+ </CollapsibleContent>
388
+ </Collapsible>
232
389
  );
233
390
  }