use-agentz 0.1.0

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 (113) hide show
  1. package/README.md +308 -0
  2. package/dist/authoring/define.js +132 -0
  3. package/dist/cli.js +26581 -0
  4. package/dist/config/define.js +36 -0
  5. package/dist/ui/web/index.html +16 -0
  6. package/dist/ui/web/logo.png +0 -0
  7. package/dist/ui/web/main.tsx +2406 -0
  8. package/dist/ui/web/styles.css +2338 -0
  9. package/package.json +54 -0
  10. package/registry/r/agent.analyst.json +44 -0
  11. package/registry/r/agent.designer.json +34 -0
  12. package/registry/r/agent.developer.json +31 -0
  13. package/registry/r/agent.expert-orchestrator.json +36 -0
  14. package/registry/r/agent.expert.json +33 -0
  15. package/registry/r/agent.orchestrator.json +39 -0
  16. package/registry/r/agent.quick.json +28 -0
  17. package/registry/r/agent.reviewer-anthropic.json +34 -0
  18. package/registry/r/agent.reviewer-gpt.json +34 -0
  19. package/registry/r/agent.scout.json +34 -0
  20. package/registry/r/command.caveman.json +20 -0
  21. package/registry/r/command.grill-me.json +20 -0
  22. package/registry/r/group.cli-bun.json +13 -0
  23. package/registry/r/group.cli-go.json +13 -0
  24. package/registry/r/group.database-design.json +12 -0
  25. package/registry/r/group.design.json +14 -0
  26. package/registry/r/group.documents.json +13 -0
  27. package/registry/r/group.engineering-process.json +18 -0
  28. package/registry/r/group.go.json +21 -0
  29. package/registry/r/group.python.json +10 -0
  30. package/registry/r/group.react.json +11 -0
  31. package/registry/r/group.typescript.json +10 -0
  32. package/registry/r/lsp.proto.json +41 -0
  33. package/registry/r/mcp.context7.json +13 -0
  34. package/registry/r/mcp.playwright.json +50 -0
  35. package/registry/r/pack.basic.json +78 -0
  36. package/registry/r/pack.gpt-5-6.json +78 -0
  37. package/registry/r/pack.optimized.json +78 -0
  38. package/registry/r/pack.opus-fable.json +78 -0
  39. package/registry/r/plugin.caveman-compress.json +20 -0
  40. package/registry/r/preset.caveman-only.json +13 -0
  41. package/registry/r/preset.cost-aware-trio.json +41 -0
  42. package/registry/r/preset.cross-model-review.json +34 -0
  43. package/registry/r/preset.design.json +39 -0
  44. package/registry/r/preset.fable-pack.json +41 -0
  45. package/registry/r/preset.full.json +67 -0
  46. package/registry/r/rule.always-cross-review.json +25 -0
  47. package/registry/r/rule.always-design.json +25 -0
  48. package/registry/r/rule.always-qa.json +25 -0
  49. package/registry/r/rule.always-test.json +25 -0
  50. package/registry/r/rule.caveman-ultra.json +23 -0
  51. package/registry/r/rule.never-cross-review.json +25 -0
  52. package/registry/r/rule.never-design.json +25 -0
  53. package/registry/r/rule.never-qa.json +25 -0
  54. package/registry/r/rule.never-test.json +25 -0
  55. package/registry/r/rule.routing.json +26 -0
  56. package/registry/r/rule.use-question-tool.json +20 -0
  57. package/registry/r/skill.bun-cli.json +20 -0
  58. package/registry/r/skill.bun-tui.json +20 -0
  59. package/registry/r/skill.caveman-compress.json +17 -0
  60. package/registry/r/skill.caveman.json +20 -0
  61. package/registry/r/skill.cli-design.json +15 -0
  62. package/registry/r/skill.code-review.json +18 -0
  63. package/registry/r/skill.cost-aware-orchestration.json +14 -0
  64. package/registry/r/skill.cross-model-review.json +14 -0
  65. package/registry/r/skill.db-schema-design.json +15 -0
  66. package/registry/r/skill.design-system.json +19 -0
  67. package/registry/r/skill.design-taste.json +116 -0
  68. package/registry/r/skill.diagnosing-bugs.json +23 -0
  69. package/registry/r/skill.diagrams.json +21 -0
  70. package/registry/r/skill.docx-create.json +20 -0
  71. package/registry/r/skill.domain-modeling.json +18 -0
  72. package/registry/r/skill.frontend-qa.json +19 -0
  73. package/registry/r/skill.go-architecture.json +21 -0
  74. package/registry/r/skill.go-ci.json +21 -0
  75. package/registry/r/skill.go-cli.json +20 -0
  76. package/registry/r/skill.go-data-structures.json +21 -0
  77. package/registry/r/skill.go-dependencies.json +21 -0
  78. package/registry/r/skill.go-dev.json +20 -0
  79. package/registry/r/skill.go-documentation.json +21 -0
  80. package/registry/r/skill.go-ecosystem.json +21 -0
  81. package/registry/r/skill.go-qa.json +21 -0
  82. package/registry/r/skill.go-quality.json +21 -0
  83. package/registry/r/skill.go-refactoring.json +21 -0
  84. package/registry/r/skill.go-tooling.json +21 -0
  85. package/registry/r/skill.go-tui.json +20 -0
  86. package/registry/r/skill.go-types.json +21 -0
  87. package/registry/r/skill.grilling.json +18 -0
  88. package/registry/r/skill.improve-architecture.json +18 -0
  89. package/registry/r/skill.pdf-create.json +23 -0
  90. package/registry/r/skill.pptx-create.json +23 -0
  91. package/registry/r/skill.product-ui.json +22 -0
  92. package/registry/r/skill.python-dev.json +20 -0
  93. package/registry/r/skill.react-dev.json +20 -0
  94. package/registry/r/skill.resolving-merge-conflicts.json +18 -0
  95. package/registry/r/skill.scratchpad.json +17 -0
  96. package/registry/r/skill.skill-authoring.json +17 -0
  97. package/registry/r/skill.software-design.json +28 -0
  98. package/registry/r/skill.sql-migrations.json +15 -0
  99. package/registry/r/skill.tdd.json +28 -0
  100. package/registry/r/skill.typescript-dev.json +20 -0
  101. package/registry/r/skill.visual-assets.json +17 -0
  102. package/registry/r/skill.wayfinder.json +18 -0
  103. package/registry/r/skill.xlsx-create.json +20 -0
  104. package/registry/r/tool.a11y-quick.json +24 -0
  105. package/registry/r/tool.contrast-check.json +24 -0
  106. package/registry/r/tool.design-lint.json +24 -0
  107. package/registry/r/tool.design-search.json +23 -0
  108. package/registry/r/tool.pdf-create.json +23 -0
  109. package/registry/r/tool.pptx-create.json +23 -0
  110. package/registry/r/tool.scratchpad.json +68 -0
  111. package/registry/r/tool.token-validate.json +24 -0
  112. package/registry/registry.json +676 -0
  113. package/schema/agentz.schema.json +330 -0
@@ -0,0 +1,2406 @@
1
+ /**
2
+ * AgentZ UI — React SPA (single file, no router/state lib per the brief).
3
+ *
4
+ * Screens: (1) setup picker (preset cards + pack selector), (2) review as an
5
+ * app shell: main column of section cards (targets/agents/skills/tools/
6
+ * plugins/commands/MCPs) + a sticky right rail with the setup summary, config
7
+ * preview buttons (right-side sheet) and the Install CTA, (3) install
8
+ * (confirm -> progress -> result). If a lockfile/config exists we first show
9
+ * the current-install summary with a "Modify" path preloading /api/state.
10
+ *
11
+ * Inputs are Base UI primitives (@base-ui-components/react): Switch for every
12
+ * item toggle, Combobox for the model picker, ToggleGroup for segmented
13
+ * controls, Popover for the per-section "+ Add" pickers, Dialog (styled as a
14
+ * right drawer) for config previews, Collapsible for the experimental-targets
15
+ * group, Tooltip on the reset-to-default control. All unstyled, themed via
16
+ * the --as-* tokens in styles.css.
17
+ *
18
+ * strict TS throughout (tsx). No snapshot logic — plain fetch + useReducer.
19
+ */
20
+ import { Fragment, StrictMode, useEffect, useMemo, useReducer, useRef, useState } from "react"
21
+ import type * as React from "react"
22
+ import { createRoot } from "react-dom/client"
23
+ import { Collapsible } from "@base-ui-components/react/collapsible"
24
+ import { Combobox } from "@base-ui-components/react/combobox"
25
+ import { Dialog } from "@base-ui-components/react/dialog"
26
+ import { Popover } from "@base-ui-components/react/popover"
27
+ import { Switch } from "@base-ui-components/react/switch"
28
+ import { Toggle } from "@base-ui-components/react/toggle"
29
+ import { ToggleGroup } from "@base-ui-components/react/toggle-group"
30
+ import { Tooltip } from "@base-ui-components/react/tooltip"
31
+ import type {
32
+ CatalogPackTier,
33
+ CatalogResponse,
34
+ GraphResponse,
35
+ InstallResponse,
36
+ ModelsResponse,
37
+ PreviewFiles,
38
+ PreviewModel,
39
+ PreviewResponse,
40
+ ProjectInfo,
41
+ ProjectsResponse,
42
+ SessionStateResponse,
43
+ StateResponse,
44
+ ShutdownResponse,
45
+ WizardState,
46
+ } from "../api.ts"
47
+ import { SHUTDOWN_TOKEN_HEADER } from "../api.ts"
48
+
49
+ /* ================================================================== */
50
+ /* API client (project-scoped) */
51
+ /* ================================================================== */
52
+
53
+ /**
54
+ * The currently selected project (root-relative path; "" = root). Every API
55
+ * call appends it as `?project=` so state/preview/install are scoped. Kept as a
56
+ * module singleton so the debounced preview hook and one-off fetches agree
57
+ * without prop-drilling it into the API layer.
58
+ */
59
+ let currentProject = ""
60
+ let shutdownToken: string | null = null
61
+ function setCurrentProject(p: string): void {
62
+ currentProject = p
63
+ }
64
+
65
+ /** Append the active `?project=` scope (and merge any existing query). */
66
+ function scoped(path: string): string {
67
+ if (!currentProject) return path
68
+ const sep = path.includes("?") ? "&" : "?"
69
+ return `${path}${sep}project=${encodeURIComponent(currentProject)}`
70
+ }
71
+
72
+ async function apiGet<T>(path: string): Promise<T> {
73
+ const res = await fetch(scoped(path))
74
+ if (!res.ok) {
75
+ const data = (await res.json().catch(() => ({}))) as { error?: string }
76
+ throw new Error(data.error ?? `GET ${path} failed`)
77
+ }
78
+ return res.json() as Promise<T>
79
+ }
80
+
81
+ async function apiPost<T>(path: string, body: unknown): Promise<T> {
82
+ const res = await fetch(scoped(path), {
83
+ method: "POST",
84
+ headers: { "content-type": "application/json" },
85
+ body: JSON.stringify(body),
86
+ })
87
+ const data = await res.json().catch(() => ({}))
88
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? `POST ${path} failed`)
89
+ return data as T
90
+ }
91
+
92
+ /** Load current state and retain the launch-only shutdown token in memory. */
93
+ async function loadState(): Promise<StateResponse> {
94
+ const { shutdownToken: token, ...state } = await apiGet<SessionStateResponse>("/api/state")
95
+ shutdownToken = token
96
+ return state
97
+ }
98
+
99
+ /** Close requires a non-simple token header; never include it in other requests. */
100
+ async function shutdown(): Promise<ShutdownResponse> {
101
+ if (!shutdownToken) throw new Error("shutdown unavailable")
102
+ const res = await fetch("/api/shutdown", {
103
+ method: "POST",
104
+ headers: { [SHUTDOWN_TOKEN_HEADER]: shutdownToken },
105
+ })
106
+ const data = await res.json().catch(() => ({}))
107
+ if (!res.ok) throw new Error((data as { error?: string }).error ?? "shutdown failed")
108
+ return data as ShutdownResponse
109
+ }
110
+
111
+ /* ================================================================== */
112
+ /* model badges (deterministic rainbow hue per model id) */
113
+ /* ================================================================== */
114
+
115
+ /**
116
+ * Deterministic hue (0–359) for a model id. The hue is derived from the model
117
+ * NAME AFTER the last '/' (`github-copilot/claude-fable-5` → `claude-fable-5`),
118
+ * so the SAME model shares a hue across providers, and a plain FNV-1a hash of a
119
+ * fixed string makes it stable across reloads (no index/iteration-order/random
120
+ * salt involved). `>>> 0` keeps the 32-bit accumulator unsigned so `% 360` is
121
+ * always the same non-negative value.
122
+ */
123
+ export function modelHue(id: string): number {
124
+ const name = id.split("/").pop() ?? id
125
+ let h = 0x811c9dc5
126
+ for (let i = 0; i < name.length; i++) {
127
+ h ^= name.charCodeAt(i)
128
+ h = Math.imul(h, 0x01000193)
129
+ }
130
+ return (h >>> 0) % 360
131
+ }
132
+
133
+ /**
134
+ * A colored model chip: hue derived from the model name, via --model-hue.
135
+ * Optionally renders the reasoning effort as a `: effort` suffix so an agent
136
+ * row reads `provider/model : high` in one mono badge.
137
+ */
138
+ function ModelBadge(props: { model: string; effort?: string | undefined }): React.JSX.Element {
139
+ const hue = modelHue(props.model)
140
+ return (
141
+ <span
142
+ className="as-model-badge"
143
+ style={{ ["--model-hue" as string]: String(hue) }}
144
+ title={props.effort ? `${props.model} : ${props.effort}` : props.model}
145
+ >
146
+ {props.model}
147
+ {props.effort ? ` : ${props.effort}` : ""}
148
+ </span>
149
+ )
150
+ }
151
+
152
+ /* ================================================================== */
153
+ /* catalog dependency graph (read-only hints, degrade silently) */
154
+ /* ================================================================== */
155
+
156
+ /** Lookup maps derived once from GET /api/graph. All UI hints built on top of
157
+ * these degrade to nothing when the fetch fails (GraphInfo stays null). */
158
+ interface GraphInfo {
159
+ /** node id -> human label */
160
+ labels: Map<string, string>
161
+ /** node id -> dependency target ids (manifest `dependencies` edges) */
162
+ requires: Map<string, string[]>
163
+ /** node id -> number of dependency edges pointing AT it */
164
+ requiredBy: Map<string, number>
165
+ /** preset/pack id -> member ids (includes + extra edges, groups expanded) */
166
+ members: Map<string, string[]>
167
+ /** group node id (`group:x`) -> its member skill ids (`skill:y`), raw. */
168
+ groups: Map<string, string[]>
169
+ }
170
+
171
+ function buildGraphInfo(g: GraphResponse): GraphInfo {
172
+ const labels = new Map<string, string>()
173
+ for (const n of g.nodes) labels.set(n.id, n.label)
174
+ const requires = new Map<string, string[]>()
175
+ const requiredBy = new Map<string, number>()
176
+ const direct = new Map<string, string[]>()
177
+ const groups = new Map<string, string[]>()
178
+ for (const e of g.edges) {
179
+ if (e.kind === "dependency") {
180
+ requires.set(e.from, [...(requires.get(e.from) ?? []), e.to])
181
+ requiredBy.set(e.to, (requiredBy.get(e.to) ?? 0) + 1)
182
+ } else if (e.kind === "group") {
183
+ groups.set(e.from, [...(groups.get(e.from) ?? []), e.to])
184
+ } else {
185
+ // includes (preset -> items) and extra (pack -> bundled items)
186
+ direct.set(e.from, [...(direct.get(e.from) ?? []), e.to])
187
+ }
188
+ }
189
+ // Expand group members into the owning preset's member list (group node
190
+ // itself dropped from counts; its skills are what actually gets installed).
191
+ const members = new Map<string, string[]>()
192
+ for (const [owner, ids] of direct) {
193
+ const out: string[] = []
194
+ for (const id of ids) {
195
+ if (id.startsWith("group:")) out.push(...(groups.get(id) ?? []))
196
+ else out.push(id)
197
+ }
198
+ members.set(owner, [...new Set(out)])
199
+ }
200
+ return { labels, requires, requiredBy, members, groups }
201
+ }
202
+
203
+ /** "3 agents · 4 skills" style composition summary for a preset/pack card. */
204
+ function memberCounts(info: GraphInfo, ownerId: string): string | null {
205
+ const ids = info.members.get(ownerId)
206
+ if (!ids || ids.length === 0) return null
207
+ const counts = new Map<string, number>()
208
+ for (const id of ids) {
209
+ const type = id.split(":")[0] ?? "item"
210
+ counts.set(type, (counts.get(type) ?? 0) + 1)
211
+ }
212
+ return [...counts.entries()]
213
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
214
+ .map(([type, n]) => `${n} ${REF_TYPE_LABELS[type] ?? `${type}s`}`.replace(/s$/, n === 1 ? "" : "s"))
215
+ .join(" · ")
216
+ }
217
+
218
+ /**
219
+ * Dependency-lock map for the review screen: every item id that is required
220
+ * (directly or transitively) by a currently-enabled item, mapped to the human
221
+ * labels of its DIRECT enabled requirers. Locked items render a locked-ON
222
+ * Switch with an inline "required by X, Y" note; graph==null -> empty map ->
223
+ * nothing locks and the UI behaves like before.
224
+ */
225
+ function computeLocks(info: GraphInfo | null, enabled: Set<string>): Map<string, string[]> {
226
+ const locked = new Map<string, string[]>()
227
+ if (!info) return locked
228
+ const queue = [...enabled]
229
+ const seen = new Set(queue)
230
+ while (queue.length > 0) {
231
+ const id = queue.shift()!
232
+ const fromLabel = info.labels.get(id) ?? id.split(":").pop() ?? id
233
+ for (const dep of info.requires.get(id) ?? []) {
234
+ const list = locked.get(dep) ?? []
235
+ if (!list.includes(fromLabel)) list.push(fromLabel)
236
+ locked.set(dep, list)
237
+ if (!seen.has(dep)) {
238
+ seen.add(dep)
239
+ queue.push(dep) // transitive: a locked dep drags in ITS deps too
240
+ }
241
+ }
242
+ }
243
+ return locked
244
+ }
245
+
246
+ /**
247
+ * Compact dependency hint under an item row: up to 3 "requires" chips with a
248
+ * "+n more" expander, plus a "needed by N" chip. Renders nothing without graph
249
+ * data, so a failed /api/graph fetch leaves rows untouched.
250
+ */
251
+ function DepChips(props: { info: GraphInfo | null; kind: string; name: string }): React.JSX.Element | null {
252
+ const [expanded, setExpanded] = useState(false)
253
+ if (!props.info) return null
254
+ const id = `${props.kind}:${props.name}`
255
+ const req = props.info.requires.get(id) ?? []
256
+ const by = props.info.requiredBy.get(id) ?? 0
257
+ if (req.length === 0 && by === 0) return null
258
+ const shown = expanded ? req : req.slice(0, 3)
259
+ const hidden = req.length - shown.length
260
+ return (
261
+ <span className="as-dep-chips">
262
+ {req.length > 0 && <span className="as-dep-label">requires</span>}
263
+ {shown.map((dep) => (
264
+ <span className="as-dep-chip" key={dep} title={dep}>
265
+ {props.info!.labels.get(dep) ?? dep.split(":").pop()}
266
+ </span>
267
+ ))}
268
+ {hidden > 0 && (
269
+ <button type="button" className="as-dep-more" onClick={() => setExpanded(true)}>
270
+ +{hidden} more
271
+ </button>
272
+ )}
273
+ {by > 0 && (
274
+ <span className="as-dep-chip" title={`${by} other item(s) depend on this`}>
275
+ needed by {by}
276
+ </span>
277
+ )}
278
+ </span>
279
+ )
280
+ }
281
+
282
+ /* ================================================================== */
283
+ /* wizard state (mirrors src/wizard/shared.ts WizardState) */
284
+ /* ================================================================== */
285
+
286
+ type Screen = "loading" | "current" | "setup" | "review" | "installing" | "done" | "error"
287
+
288
+ function defaultState(catalog: CatalogResponse): WizardState {
289
+ return {
290
+ presetNames: [catalog.defaultPreset],
291
+ modelPack: catalog.defaultPack,
292
+ targets: (catalog.targets.filter((t) => t.detected).map((t) => t.id) as WizardState["targets"]) ?? [],
293
+ scope: "project",
294
+ vars: { scratchpad: true },
295
+ agents: {},
296
+ tierThinking: {},
297
+ excluded: { skill: [], tool: [], plugin: [], mcp: [], rule: [], lsp: [] },
298
+ mcps: {},
299
+ }
300
+ }
301
+
302
+ type Action =
303
+ | { t: "preset"; name: string }
304
+ | { t: "pack"; pack: string }
305
+ | { t: "targets"; targets: string[] }
306
+ | { t: "scope"; scope: "project" | "user" }
307
+ | { t: "agentsMd"; mode: string }
308
+ | { t: "agentModel"; agentId: string; model: string }
309
+ | { t: "agentEffort"; agentId: string; effort: string }
310
+ | { t: "tierModel"; tier: "cheap" | "normal" | "expert"; model: string }
311
+ | { t: "tierEffort"; tier: "cheap" | "normal" | "expert"; effort: string }
312
+ | { t: "toggle"; kind: keyof WizardState["excluded"]; name: string; on: boolean }
313
+ | { t: "mcpOption"; mcp: string; option: string; on: boolean }
314
+ | { t: "extra"; kind: ExtraKind; name: string; on: boolean }
315
+ | { t: "replace"; state: WizardState }
316
+
317
+ /** Catalog kinds bulk-addable via the review "+ Add" popovers. */
318
+ type ExtraKind = "agent" | "skill" | "tool" | "plugin" | "mcp" | "command" | "rule" | "lsp"
319
+
320
+ function reducer(state: WizardState, a: Action): WizardState {
321
+ switch (a.t) {
322
+ case "replace":
323
+ return a.state
324
+ case "preset": {
325
+ // Multi-select: toggle membership (keep ≥0; Review button guards ≥1).
326
+ const has = state.presetNames.includes(a.name)
327
+ const presetNames = has
328
+ ? state.presetNames.filter((n) => n !== a.name)
329
+ : [...state.presetNames, a.name]
330
+ return { ...state, presetNames }
331
+ }
332
+ case "pack":
333
+ return { ...state, modelPack: a.pack }
334
+ case "targets":
335
+ return { ...state, targets: a.targets as WizardState["targets"] }
336
+ case "scope":
337
+ return { ...state, scope: a.scope }
338
+ case "agentsMd":
339
+ return { ...state, agentsMd: a.mode === "off" ? false : (a.mode as "lean" | "full") }
340
+ case "agentModel": {
341
+ const agents = { ...state.agents }
342
+ const cur = { ...(agents[a.agentId] ?? {}) }
343
+ if (a.model) cur.model = a.model
344
+ else delete cur.model
345
+ if (Object.keys(cur).length) agents[a.agentId] = cur
346
+ else delete agents[a.agentId]
347
+ return { ...state, agents }
348
+ }
349
+ case "tierModel": {
350
+ // Per-tier model override, stored as vars["model.<tier>"] — the same key
351
+ // the planner reads at the top of the tier-default precedence. Empty =
352
+ // clear (fall back to the selected pack's tier model).
353
+ const key = `model.${a.tier}`
354
+ const vars = { ...state.vars }
355
+ if (a.model) vars[key] = a.model
356
+ else delete vars[key]
357
+ return { ...state, vars }
358
+ }
359
+ case "tierEffort": {
360
+ // Per-tier reasoning-effort default, stored under tierThinking[tier].
361
+ // Empty = clear the tier entry (fall back to the pack's tier effort); the
362
+ // reducer never writes an empty block.
363
+ const tierThinking = { ...state.tierThinking }
364
+ const cur = { ...(tierThinking[a.tier] ?? {}) }
365
+ if (a.effort) cur.reasoningEffort = a.effort as never
366
+ else delete cur.reasoningEffort
367
+ if (Object.keys(cur).length) tierThinking[a.tier] = cur
368
+ else delete tierThinking[a.tier]
369
+ return { ...state, tierThinking }
370
+ }
371
+ case "agentEffort": {
372
+ const agents = { ...state.agents }
373
+ const cur = { ...(agents[a.agentId] ?? {}) }
374
+ if (a.effort) cur.thinking = { ...(cur.thinking ?? {}), reasoningEffort: a.effort as never }
375
+ else if (cur.thinking) {
376
+ const { reasoningEffort: _drop, ...rest } = cur.thinking
377
+ if (Object.keys(rest).length) cur.thinking = rest
378
+ else delete cur.thinking
379
+ }
380
+ if (Object.keys(cur).length) agents[a.agentId] = cur
381
+ else delete agents[a.agentId]
382
+ return { ...state, agents }
383
+ }
384
+ case "toggle": {
385
+ const list = state.excluded[a.kind]
386
+ const has = list.includes(a.name)
387
+ const next = a.on
388
+ ? has
389
+ ? list.filter((n) => n !== a.name)
390
+ : list
391
+ : has
392
+ ? list
393
+ : [...list, a.name]
394
+ return { ...state, excluded: { ...state.excluded, [a.kind]: next } }
395
+ }
396
+ case "mcpOption": {
397
+ const mcps = { ...state.mcps }
398
+ const bucket = { ...(mcps[a.mcp] ?? {}) }
399
+ const options = { ...(bucket.options ?? {}) }
400
+ options[a.option] = a.on
401
+ bucket.options = options
402
+ mcps[a.mcp] = bucket
403
+ return { ...state, mcps }
404
+ }
405
+ case "extra": {
406
+ const extra = { ...(state.extra ?? {}) }
407
+ const list = extra[a.kind] ?? []
408
+ const has = list.includes(a.name)
409
+ const next = a.on ? (has ? list : [...list, a.name]) : list.filter((n) => n !== a.name)
410
+ if (next.length) extra[a.kind] = next
411
+ else delete extra[a.kind]
412
+ // An extra item is never simultaneously "excluded": adding or removing an
413
+ // extra clears any stale exclusion for the same name so it can never fall
414
+ // into the excluded∧extra limbo (dropped from the preview by filterExcluded
415
+ // → no row, yet still in `extra` → filtered out of the Add popover). Only
416
+ // exclusion buckets exist (agent/command have none) so guard the lookup.
417
+ const exKind = a.kind as keyof WizardState["excluded"]
418
+ const exList = state.excluded[exKind]
419
+ const excluded =
420
+ exList && exList.includes(a.name)
421
+ ? { ...state.excluded, [exKind]: exList.filter((n) => n !== a.name) }
422
+ : state.excluded
423
+ return {
424
+ ...state,
425
+ excluded,
426
+ extra: Object.keys(extra).length ? extra : undefined,
427
+ }
428
+ }
429
+ }
430
+ }
431
+
432
+ /* ================================================================== */
433
+ /* preview hook (debounced refetch on every state change) */
434
+ /* ================================================================== */
435
+
436
+ function usePreview(state: WizardState, active: boolean): {
437
+ preview: PreviewModel | null
438
+ files: PreviewFiles | null
439
+ deletions: PreviewResponse["deletions"]
440
+ loading: boolean
441
+ error: string | null
442
+ } {
443
+ const [preview, setPreview] = useState<PreviewModel | null>(null)
444
+ const [files, setFiles] = useState<PreviewFiles | null>(null)
445
+ const [deletions, setDeletions] = useState<PreviewResponse["deletions"]>([])
446
+ const [loading, setLoading] = useState(false)
447
+ const [error, setError] = useState<string | null>(null)
448
+ const seq = useRef(0)
449
+
450
+ useEffect(() => {
451
+ if (!active) return
452
+ const id = ++seq.current
453
+ setLoading(true)
454
+ const timer = setTimeout(() => {
455
+ apiPost<PreviewResponse>("/api/preview", { state })
456
+ .then((r) => {
457
+ if (seq.current === id) {
458
+ setPreview(r.preview)
459
+ setFiles(r.files)
460
+ setDeletions(r.deletions ?? [])
461
+ setError(null)
462
+ }
463
+ })
464
+ .catch((e: Error) => {
465
+ if (seq.current === id) setError(e.message)
466
+ })
467
+ .finally(() => {
468
+ if (seq.current === id) setLoading(false)
469
+ })
470
+ }, 180)
471
+ return () => clearTimeout(timer)
472
+ }, [state, active])
473
+
474
+ return { preview, files, deletions, loading, error }
475
+ }
476
+
477
+ /* ================================================================== */
478
+ /* Base UI wrappers (styled with --as-* tokens) */
479
+ /* ================================================================== */
480
+
481
+ /**
482
+ * Item toggle: Base UI Switch (zinc track, cobalt when checked). `locked`
483
+ * renders it ON, read-only and dimmed, with a lock glyph; the inline
484
+ * "required by ..." note is rendered by the caller so clicking never toggles
485
+ * a dependency another enabled item still needs.
486
+ */
487
+ function ItemSwitch(props: {
488
+ checked: boolean
489
+ onChange?: (on: boolean) => void
490
+ label: string
491
+ locked?: boolean
492
+ disabled?: boolean
493
+ describedById?: string
494
+ }): React.JSX.Element {
495
+ return (
496
+ <span className="as-switch-wrap">
497
+ {props.locked && <LockGlyph />}
498
+ <Switch.Root
499
+ className="as-switch"
500
+ checked={props.locked ? true : props.checked}
501
+ readOnly={props.locked === true}
502
+ disabled={props.disabled === true}
503
+ aria-label={props.label}
504
+ aria-describedby={props.locked && props.describedById ? props.describedById : undefined}
505
+ onCheckedChange={(on) => {
506
+ if (!props.locked && props.onChange) props.onChange(on)
507
+ }}
508
+ >
509
+ <Switch.Thumb className="as-switch-thumb" />
510
+ </Switch.Root>
511
+ </span>
512
+ )
513
+ }
514
+
515
+ /** Small lock glyph shown next to a dependency-locked switch. */
516
+ function LockGlyph(): React.JSX.Element {
517
+ return (
518
+ <svg className="as-lock" width="11" height="11" viewBox="0 0 12 12" aria-hidden="true" focusable="false">
519
+ <rect x="2" y="5" width="8" height="6" rx="1.5" fill="currentColor" />
520
+ <path d="M4 5V3.5a2 2 0 0 1 4 0V5" stroke="currentColor" strokeWidth="1.4" fill="none" />
521
+ </svg>
522
+ )
523
+ }
524
+
525
+ /** Inline "required by X, Y" note under a locked row. */
526
+ function LockNote(props: { requirers: string[]; id?: string }): React.JSX.Element {
527
+ return (
528
+ <span className="as-lock-note" id={props.id}>
529
+ required by {props.requirers.slice(0, 3).join(", ")}
530
+ {props.requirers.length > 3 ? ` +${props.requirers.length - 3} more` : ""}
531
+ </span>
532
+ )
533
+ }
534
+
535
+ /**
536
+ * Segmented control (Base UI ToggleGroup, single-select). Empty selections
537
+ * are ignored so one segment is always active.
538
+ */
539
+ function Seg(props: {
540
+ value: string
541
+ options: { value: string; label: string }[]
542
+ onChange: (v: string) => void
543
+ ariaLabel: string
544
+ }): React.JSX.Element {
545
+ return (
546
+ <ToggleGroup
547
+ className="as-seg"
548
+ value={[props.value]}
549
+ aria-label={props.ariaLabel}
550
+ onValueChange={(vals) => {
551
+ const v = vals[0]
552
+ if (typeof v === "string" && v !== props.value) props.onChange(v)
553
+ }}
554
+ >
555
+ {props.options.map((o) => (
556
+ <Toggle key={o.value} value={o.value} className="as-seg-item">
557
+ {o.label}
558
+ </Toggle>
559
+ ))}
560
+ </ToggleGroup>
561
+ )
562
+ }
563
+
564
+ /**
565
+ * Compact accessible dropdown (native `<select>`, styled to match `.as-input`).
566
+ * Used where a full segmented control would not fit — e.g. the three tier
567
+ * columns of reasoning-effort defaults at 1024px. `value === ""` selects the
568
+ * first (placeholder) option. Native semantics give full keyboard + screen
569
+ * reader support for free.
570
+ */
571
+ function NativeSelect(props: {
572
+ id: string
573
+ value: string
574
+ options: { value: string; label: string }[]
575
+ onChange: (v: string) => void
576
+ ariaLabel: string
577
+ }): React.JSX.Element {
578
+ return (
579
+ <select
580
+ id={props.id}
581
+ className="as-select"
582
+ aria-label={props.ariaLabel}
583
+ value={props.value}
584
+ onChange={(e) => props.onChange(e.target.value)}
585
+ >
586
+ {props.options.map((o) => (
587
+ <option key={o.value} value={o.value}>
588
+ {o.label}
589
+ </option>
590
+ ))}
591
+ </select>
592
+ )
593
+ }
594
+
595
+ /**
596
+ * Model override picker. With a model list: a searchable Base UI Combobox
597
+ * grouped by provider (mono, keyboard navigable, clearable). Without one:
598
+ * a plain free-text input committing on blur (same as the wizard).
599
+ */
600
+ function ModelPicker(props: {
601
+ id: string
602
+ models: ModelsResponse | null
603
+ placeholder: string
604
+ value: string
605
+ onChange: (v: string) => void
606
+ }): React.JSX.Element {
607
+ const available = props.models?.available ?? false
608
+ const hasModels = (props.models?.models.length ?? 0) > 0
609
+ // Local draft for the free-text fallback so typing is smooth; synced back to
610
+ // props.value whenever it changes (reset / state replace), so it never goes
611
+ // stale after the parent swaps state under it.
612
+ const [draft, setDraft] = useState(props.value)
613
+ useEffect(() => {
614
+ setDraft(props.value)
615
+ }, [props.value])
616
+ const groups = useMemo(() => {
617
+ if (!props.models) return []
618
+ const by = new Map<string, string[]>()
619
+ for (const m of props.models.models) {
620
+ const provider = m.id.includes("/") ? m.id.split("/")[0]! : "other"
621
+ by.set(provider, [...(by.get(provider) ?? []), m.id])
622
+ }
623
+ return [...by.entries()]
624
+ .sort((a, b) => a[0].localeCompare(b[0]))
625
+ .map(([value, items]) => ({ value, items: items.sort() }))
626
+ }, [props.models])
627
+
628
+ if (!available || !hasModels) {
629
+ return (
630
+ <input
631
+ id={props.id}
632
+ className="as-input"
633
+ placeholder={props.placeholder}
634
+ value={draft}
635
+ onChange={(e) => setDraft(e.target.value)}
636
+ onBlur={(e) => props.onChange(e.target.value.trim())}
637
+ />
638
+ )
639
+ }
640
+ return (
641
+ <Combobox.Root
642
+ items={groups}
643
+ value={props.value || null}
644
+ onValueChange={(v) => props.onChange(typeof v === "string" ? v : "")}
645
+ >
646
+ <div className="as-combo">
647
+ <Combobox.Input id={props.id} className="as-input" placeholder={props.placeholder} />
648
+ {props.value && (
649
+ <Combobox.Clear className="as-combo-clear" aria-label="clear model override">
650
+ ×
651
+ </Combobox.Clear>
652
+ )}
653
+ </div>
654
+ <Combobox.Portal>
655
+ <Combobox.Positioner className="as-positioner" sideOffset={4}>
656
+ <Combobox.Popup className="as-popover as-combo-popup">
657
+ <Combobox.Empty className="as-combo-empty">no matching models</Combobox.Empty>
658
+ <Combobox.List className="as-combo-list">
659
+ {(group: { value: string; items: string[] }) => (
660
+ <Combobox.Group key={group.value} items={group.items} className="as-combo-group">
661
+ <Combobox.GroupLabel className="as-combo-group-label">{group.value}</Combobox.GroupLabel>
662
+ <Combobox.Collection>
663
+ {(item: string) => (
664
+ <Combobox.Item key={item} value={item} className="as-combo-item">
665
+ <Combobox.ItemIndicator className="as-combo-indicator">
666
+ <CheckIcon />
667
+ </Combobox.ItemIndicator>
668
+ <span className="as-combo-item-text">{item}</span>
669
+ </Combobox.Item>
670
+ )}
671
+ </Combobox.Collection>
672
+ </Combobox.Group>
673
+ )}
674
+ </Combobox.List>
675
+ </Combobox.Popup>
676
+ </Combobox.Positioner>
677
+ </Combobox.Portal>
678
+ </Combobox.Root>
679
+ )
680
+ }
681
+
682
+ const TIER_ORDER = ["cheap", "normal", "expert"] as const
683
+ type Tier = (typeof TIER_ORDER)[number]
684
+
685
+ /**
686
+ * Per-tier model overrides — the PRIMARY override path. One compact ModelPicker
687
+ * per tier (cheap/normal/expert) writing `state.vars['model.<tier>']` (the same
688
+ * key the planner reads at the top of the tier-default precedence), so a value
689
+ * here applies to every agent resolved to that tier, plus a compact reasoning
690
+ * effort dropdown writing `state.tierThinking[tier]`. Per-agent overrides (the
691
+ * Agents section, behind the "Override" switch) take priority over
692
+ * both. Each picker's placeholder is the selected pack's tier model, or a
693
+ * plain "keep pack default" fallback when the pack does not define that tier.
694
+ * The effort dropdown defaults to "pack default" (shows the pack's tier effort
695
+ * as its label); clearing it deletes the tier entry (reducer drops the block).
696
+ *
697
+ * The "Model overrides" title is owned by the parent nested-collapsible trigger,
698
+ * so this renders only the description + the three tier fields.
699
+ */
700
+ function TierOverrides(props: {
701
+ catalog: CatalogResponse
702
+ models: ModelsResponse | null
703
+ state: WizardState
704
+ dispatch: React.Dispatch<Action>
705
+ }): React.JSX.Element {
706
+ const { catalog, models, state, dispatch } = props
707
+ const pack = catalog.packs.find((p) => p.id === state.modelPack)
708
+ const packTier = (tier: Tier): CatalogPackTier | undefined =>
709
+ pack?.tiers.find((t) => t.tier === tier)
710
+ const tierModel = (tier: Tier): string | undefined => packTier(tier)?.model
711
+ return (
712
+ <div className="as-row as-tier-overrides">
713
+ <div className="as-row-main">
714
+ <div className="as-row-desc">
715
+ Applies to every agent in a tier. Per-agent overrides take priority.
716
+ </div>
717
+ </div>
718
+ <div className="as-tier-fields">
719
+ {TIER_ORDER.map((tier) => {
720
+ const id = `as-tier-${tier}`
721
+ const effortId = `as-tier-effort-${tier}`
722
+ const raw = state.vars[`model.${tier}`]
723
+ const value = typeof raw === "string" ? raw : ""
724
+ const fallback = tierModel(tier)
725
+ const packEffort = packTier(tier)?.effort
726
+ const effort = state.tierThinking[tier]?.reasoningEffort ?? ""
727
+ const packLabel = packEffort ? `pack default (${packEffort})` : "pack default"
728
+ return (
729
+ <div className="as-tier-field" key={tier}>
730
+ <label className="as-label" htmlFor={id}>
731
+ {tier} <span className="as-tier-hint">tier</span>
732
+ </label>
733
+ <ModelPicker
734
+ id={id}
735
+ models={models}
736
+ placeholder={fallback ?? "keep pack default"}
737
+ value={value}
738
+ onChange={(v) => dispatch({ t: "tierModel", tier, model: v })}
739
+ />
740
+ <label className="as-tier-sublabel" htmlFor={effortId}>
741
+ Reasoning effort
742
+ </label>
743
+ <NativeSelect
744
+ id={effortId}
745
+ ariaLabel={`${tier} tier reasoning effort`}
746
+ value={effort}
747
+ onChange={(v) => dispatch({ t: "tierEffort", tier, effort: v })}
748
+ options={[
749
+ { value: "", label: packLabel },
750
+ ...catalog.reasoningEfforts.map((r) => ({ value: r, label: r })),
751
+ ]}
752
+ />
753
+ </div>
754
+ )
755
+ })}
756
+ </div>
757
+ </div>
758
+ )
759
+ }
760
+
761
+ function CheckIcon(): React.JSX.Element {
762
+ return (
763
+ <svg width="10" height="10" viewBox="0 0 10 10" fill="none" aria-hidden="true" focusable="false">
764
+ <path
765
+ d="M1.5 5.5 L4 8 L8.5 2.5"
766
+ stroke="currentColor"
767
+ strokeWidth="1.8"
768
+ strokeLinecap="round"
769
+ strokeLinejoin="round"
770
+ />
771
+ </svg>
772
+ )
773
+ }
774
+
775
+ type AddItem = { name: string; label: string; description: string }
776
+
777
+ /** One selectable row inside the add popover. */
778
+ function AddRow(props: {
779
+ item: AddItem
780
+ on: boolean
781
+ onToggle: () => void
782
+ }): React.JSX.Element {
783
+ return (
784
+ <button type="button" className="as-add-item" aria-pressed={props.on} onClick={props.onToggle} title={props.item.description || undefined}>
785
+ <span className="as-add-check" data-on={props.on}>
786
+ {props.on && <CheckIcon />}
787
+ </span>
788
+ <span className="as-add-main">
789
+ <span className="as-row-name">{props.item.label}</span>
790
+ {props.item.description && <span className="as-row-desc">{props.item.description}</span>}
791
+ </span>
792
+ </button>
793
+ )
794
+ }
795
+
796
+ /**
797
+ * Per-section "+ Add" popover: search input on top, keyboard-navigable
798
+ * multiselect checklist below. Items already shown in the section (present in
799
+ * the preview list, or already added as extras) are filtered out — you can
800
+ * only add things not there yet; when nothing remains a note says so. Stays
801
+ * open across picks so several items can be added in one visit.
802
+ *
803
+ * For skills WITH graph data the list is grouped: each skills group renders a
804
+ * header (label + member count badge + "Add all" that adds every not-yet-shown
805
+ * member), its members underneath, and any ungrouped skills follow under an
806
+ * "other" header. Without graph data (or for other kinds) it is a flat list.
807
+ */
808
+ function AddPopover(props: {
809
+ kind: ExtraKind
810
+ label: string
811
+ items: AddItem[]
812
+ /** Names already added as extras (rendered as rows, toggle off on re-click). */
813
+ added: string[]
814
+ /** Names already present in the section (preview list) — filtered out. */
815
+ present?: string[]
816
+ graph: GraphInfo | null
817
+ dispatch: React.Dispatch<Action>
818
+ }): React.JSX.Element | null {
819
+ const [query, setQuery] = useState("")
820
+ const listRef = useRef<HTMLDivElement | null>(null)
821
+ if (props.items.length === 0) return null
822
+
823
+ // Hide anything already visible in the section: present in the preview list,
824
+ // or already added as an extra. What remains is genuinely addable.
825
+ const shown = new Set([...(props.present ?? []), ...props.added])
826
+ const available = props.items.filter((i) => !shown.has(i.name))
827
+ const nothingLeft = available.length === 0
828
+
829
+ const q = query.trim().toLowerCase()
830
+ const matches = (i: AddItem): boolean =>
831
+ !q ||
832
+ i.name.toLowerCase().includes(q) ||
833
+ i.label.toLowerCase().includes(q) ||
834
+ i.description.toLowerCase().includes(q)
835
+ const filtered = available.filter(matches)
836
+
837
+ // Group-aware layout for skills when graph data exists: group headers with an
838
+ // "Add all", members under each, ungrouped skills under an "other" header.
839
+ const grouped = props.kind === "skill" && props.graph !== null
840
+ const byName = new Map(filtered.map((i) => [i.name, i]))
841
+ const sections: { key: string; label: string; members: AddItem[] }[] = []
842
+ if (grouped) {
843
+ const claimed = new Set<string>()
844
+ for (const [gid, memberIds] of props.graph!.groups) {
845
+ const members: AddItem[] = []
846
+ for (const mid of memberIds) {
847
+ const name = mid.startsWith("skill:") ? mid.slice("skill:".length) : mid
848
+ const item = byName.get(name)
849
+ if (item) {
850
+ members.push(item)
851
+ claimed.add(name)
852
+ }
853
+ }
854
+ if (members.length > 0) {
855
+ sections.push({ key: gid, label: props.graph!.labels.get(gid) ?? gid.split(":").pop() ?? gid, members })
856
+ }
857
+ }
858
+ sections.sort((a, b) => a.label.localeCompare(b.label))
859
+ const other = filtered.filter((i) => !claimed.has(i.name))
860
+ if (other.length > 0) sections.push({ key: "__other", label: "other", members: other })
861
+ }
862
+
863
+ function onListKeyDown(e: React.KeyboardEvent): void {
864
+ if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
865
+ e.preventDefault()
866
+ const options = [...(listRef.current?.querySelectorAll<HTMLButtonElement>(".as-add-item") ?? [])]
867
+ if (options.length === 0) return
868
+ const idx = options.indexOf(document.activeElement as HTMLButtonElement)
869
+ // From the search input (idx === -1): ArrowDown → first, ArrowUp → last.
870
+ // Within the list: step and wrap at both ends.
871
+ let next: number
872
+ if (idx === -1) {
873
+ next = e.key === "ArrowDown" ? 0 : options.length - 1
874
+ } else if (e.key === "ArrowDown") {
875
+ next = (idx + 1) % options.length
876
+ } else {
877
+ next = (idx - 1 + options.length) % options.length
878
+ }
879
+ options[next]?.focus()
880
+ }
881
+
882
+ const toggle = (name: string, on: boolean): void =>
883
+ props.dispatch({ t: "extra", kind: props.kind, name, on })
884
+ const addAll = (members: AddItem[]): void => {
885
+ // Respect the present/added filter: `members` already excludes shown items.
886
+ for (const m of members) toggle(m.name, true)
887
+ }
888
+
889
+ return (
890
+ <Popover.Root onOpenChange={(open) => !open && setQuery("")}>
891
+ <Popover.Trigger className="as-btn" data-size="sm" aria-label={`Add ${props.label}`}>
892
+ + Add
893
+ </Popover.Trigger>
894
+ <Popover.Portal>
895
+ <Popover.Positioner className="as-positioner" sideOffset={6} align="end">
896
+ <Popover.Popup className="as-popover as-add-popup" aria-label={`add ${props.label}`}>
897
+ {nothingLeft ? (
898
+ <p className="as-combo-empty">everything already added</p>
899
+ ) : (
900
+ <>
901
+ <input
902
+ className="as-input as-add-search"
903
+ placeholder={`search ${props.label.toLowerCase()}`}
904
+ value={query}
905
+ aria-label={`search ${props.label}`}
906
+ onChange={(e) => setQuery(e.target.value)}
907
+ onKeyDown={onListKeyDown}
908
+ />
909
+ <div className="as-add-list" ref={listRef} onKeyDown={onListKeyDown}>
910
+ {grouped
911
+ ? sections.map((sec) => (
912
+ <div className="as-add-group" key={sec.key}>
913
+ <div className="as-add-group-head">
914
+ <span className="as-add-group-label">{sec.label}</span>
915
+ <span className="as-count as-add-group-count">{sec.members.length}</span>
916
+ {sec.key !== "__other" && (
917
+ <button
918
+ type="button"
919
+ className="as-btn"
920
+ data-size="sm"
921
+ onClick={() => addAll(sec.members)}
922
+ >
923
+ Add all
924
+ </button>
925
+ )}
926
+ </div>
927
+ {sec.members.map((item) => (
928
+ <AddRow
929
+ key={item.name}
930
+ item={item}
931
+ on={props.added.includes(item.name)}
932
+ onToggle={() => toggle(item.name, !props.added.includes(item.name))}
933
+ />
934
+ ))}
935
+ </div>
936
+ ))
937
+ : filtered.map((item) => (
938
+ <AddRow
939
+ key={item.name}
940
+ item={item}
941
+ on={props.added.includes(item.name)}
942
+ onToggle={() => toggle(item.name, !props.added.includes(item.name))}
943
+ />
944
+ ))}
945
+ {filtered.length === 0 && <p className="as-combo-empty">no matches</p>}
946
+ </div>
947
+ </>
948
+ )}
949
+ </Popover.Popup>
950
+ </Popover.Positioner>
951
+ </Popover.Portal>
952
+ </Popover.Root>
953
+ )
954
+ }
955
+
956
+ /**
957
+ * Config preview sheet: Base UI Dialog styled as a right-side drawer with an
958
+ * opencode.json / AGENTS.md tab switch. The debounced preview keeps its
959
+ * fixed-width "updating…" slot and the content dims while stale — zero
960
+ * layout shift.
961
+ */
962
+ function PreviewSheet(props: {
963
+ open: boolean
964
+ onOpenChange: (open: boolean) => void
965
+ tab: "opencode" | "agentsmd"
966
+ onTab: (tab: "opencode" | "agentsmd") => void
967
+ files: PreviewFiles | null
968
+ loading: boolean
969
+ }): React.JSX.Element {
970
+ return (
971
+ <Dialog.Root open={props.open} onOpenChange={props.onOpenChange}>
972
+ <Dialog.Portal>
973
+ <Dialog.Backdrop className="as-sheet-backdrop" />
974
+ <Dialog.Popup className="as-sheet" data-loading={props.loading}>
975
+ <div className="as-sheet-head">
976
+ <Dialog.Title className="as-sheet-title">
977
+ Generated files
978
+ <span className="as-preview-status" role="status">
979
+ {props.loading ? "updating…" : ""}
980
+ </span>
981
+ </Dialog.Title>
982
+ <Dialog.Close className="as-banner-close" aria-label="close preview">
983
+ ×
984
+ </Dialog.Close>
985
+ </div>
986
+ <Seg
987
+ ariaLabel="preview file"
988
+ value={props.tab}
989
+ onChange={(v) => props.onTab(v as "opencode" | "agentsmd")}
990
+ options={[
991
+ { value: "opencode", label: "opencode.json" },
992
+ { value: "agentsmd", label: "AGENTS.md" },
993
+ ]}
994
+ />
995
+ <pre className="as-code as-sheet-code">
996
+ {props.tab === "opencode"
997
+ ? props.files?.opencodeJson || "(nothing to write)"
998
+ : props.files?.agentsMd ?? "(AGENTS.md block disabled)"}
999
+ </pre>
1000
+ </Dialog.Popup>
1001
+ </Dialog.Portal>
1002
+ </Dialog.Root>
1003
+ )
1004
+ }
1005
+
1006
+ /* ================================================================== */
1007
+ /* app */
1008
+ /* ================================================================== */
1009
+
1010
+ function App(): React.JSX.Element {
1011
+ const [catalog, setCatalog] = useState<CatalogResponse | null>(null)
1012
+ const [existing, setExisting] = useState<StateResponse | null>(null)
1013
+ const [screen, setScreen] = useState<Screen>("loading")
1014
+ const [bootError, setBootError] = useState<string | null>(null)
1015
+ const [state, dispatch] = useReducer(reducer, null as unknown as WizardState)
1016
+ const [result, setResult] = useState<InstallResponse | null>(null)
1017
+ const [installError, setInstallError] = useState<string | null>(null)
1018
+ const [setupNotice, setSetupNotice] = useState<string | null>(null)
1019
+ const [projects, setProjects] = useState<ProjectInfo[]>([])
1020
+ const [project, setProject] = useState("")
1021
+ const [models, setModels] = useState<ModelsResponse | null>(null)
1022
+ const [banner, setBanner] = useState<string | null>(null)
1023
+ const [graph, setGraph] = useState<GraphInfo | null>(null)
1024
+ // True once the user asked to close: we POST /api/shutdown, try window.close(),
1025
+ // and (since browsers usually block script-close of a user-opened tab) swap to
1026
+ // a terminal "you can close this tab" panel.
1027
+ const [stopped, setStopped] = useState(false)
1028
+
1029
+ // Boot: load catalog + projects + models once (catalog/models are project-
1030
+ // independent). State is (re)loaded per selected project below.
1031
+ useEffect(() => {
1032
+ Promise.all([
1033
+ apiGet<CatalogResponse>("/api/catalog"),
1034
+ apiGet<ProjectsResponse>("/api/projects"),
1035
+ apiGet<ModelsResponse>("/api/models"),
1036
+ ])
1037
+ .then(([cat, proj, mods]) => {
1038
+ setCatalog(cat)
1039
+ setProjects(proj.projects)
1040
+ setModels(mods)
1041
+ })
1042
+ .catch((e: Error) => {
1043
+ setBootError(e.message)
1044
+ setScreen("error")
1045
+ })
1046
+ }, [])
1047
+
1048
+ // (Re)load the selected project's on-disk state whenever project or catalog
1049
+ // changes. Also runs on first load once catalog is ready.
1050
+ useEffect(() => {
1051
+ if (!catalog) return
1052
+ let alive = true
1053
+ const forProject = project
1054
+ setCurrentProject(project)
1055
+ loadState()
1056
+ .then((st) => {
1057
+ // Ignore a stale resolution: the project changed (or unmounted) while
1058
+ // this request was in flight, so a newer effect owns the state now.
1059
+ if (!alive || forProject !== currentProject) return
1060
+ setExisting(st)
1061
+ dispatch({ t: "replace", state: preloadState(catalog, st) })
1062
+ setScreen(st.installed ? "current" : "setup")
1063
+ })
1064
+ .catch((e: Error) => {
1065
+ if (!alive || forProject !== currentProject) return
1066
+ setBootError(e.message)
1067
+ setScreen("error")
1068
+ })
1069
+ return () => {
1070
+ alive = false
1071
+ }
1072
+ }, [catalog, project])
1073
+
1074
+ // Dependency graph, once per project. Purely additive hints: any failure
1075
+ // leaves graph=null and the UI renders without dependency chips/locks.
1076
+ useEffect(() => {
1077
+ if (!catalog) return
1078
+ let alive = true
1079
+ const forProject = project
1080
+ setCurrentProject(project)
1081
+ apiGet<GraphResponse>("/api/graph")
1082
+ .then((g) => {
1083
+ if (!alive || forProject !== currentProject) return
1084
+ setGraph(buildGraphInfo(g))
1085
+ })
1086
+ .catch(() => {
1087
+ if (!alive || forProject !== currentProject) return
1088
+ setGraph(null)
1089
+ })
1090
+ return () => {
1091
+ alive = false
1092
+ }
1093
+ }, [catalog, project])
1094
+
1095
+ const previewActive = screen === "review"
1096
+ const { preview, files, deletions, loading, error } = usePreview(state, previewActive)
1097
+
1098
+ if (screen === "loading") return <Shell><p className="as-spinner">loading…</p></Shell>
1099
+ if (screen === "error")
1100
+ return (
1101
+ <Shell>
1102
+ <p className="as-error">could not load: {bootError}</p>
1103
+ </Shell>
1104
+ )
1105
+ if (!catalog) return <Shell><p className="as-spinner">loading…</p></Shell>
1106
+
1107
+ async function doInstall(): Promise<void> {
1108
+ setScreen("installing")
1109
+ setInstallError(null)
1110
+ try {
1111
+ const r = await apiPost<InstallResponse>("/api/install", { state })
1112
+ setResult(r)
1113
+ // No dead-end: re-fetch state and return to the current/setup screen with
1114
+ // a dismissible success banner (result files/notes stay reachable).
1115
+ const st = await loadState()
1116
+ setExisting(st)
1117
+ dispatch({ t: "replace", state: preloadState(catalog!, st) })
1118
+ setBanner(`Installed ${r.installed} item(s) → ${r.configPath}`)
1119
+ setScreen(st.installed ? "current" : "setup")
1120
+ } catch (e) {
1121
+ setInstallError((e as Error).message)
1122
+ setScreen("review")
1123
+ }
1124
+ }
1125
+
1126
+ async function onModify(): Promise<void> {
1127
+ // Refetch on-disk state first: the config/lock may have changed externally
1128
+ // since the initial load, and a sync-install replaces the lock with the
1129
+ // selection closure — planning against a stale snapshot could delete or
1130
+ // resurrect items. Preload fresh state BEFORE showing the setup screen.
1131
+ try {
1132
+ const st = await loadState()
1133
+ setExisting(st)
1134
+ dispatch({ t: "replace", state: preloadState(catalog!, st) })
1135
+ if (st.presetBased) {
1136
+ setSetupNotice(null)
1137
+ } else {
1138
+ setSetupNotice(
1139
+ "This existing install was not preset-based: choose one or more presets to (re)plan on top of it; unrelated installed items stay.",
1140
+ )
1141
+ }
1142
+ } catch {
1143
+ // Fall back to the last-known `existing` snapshot on a fetch failure so
1144
+ // Modify still works offline; the notice mirrors that snapshot.
1145
+ setSetupNotice(
1146
+ existing?.presetBased
1147
+ ? null
1148
+ : "This existing install was not preset-based: choose one or more presets to (re)plan on top of it; unrelated installed items stay.",
1149
+ )
1150
+ }
1151
+ setScreen("setup")
1152
+ }
1153
+
1154
+ function onSwitchProject(p: string): void {
1155
+ setBanner(null)
1156
+ setResult(null)
1157
+ setProject(p) // triggers the state-reload effect + screen reset.
1158
+ }
1159
+
1160
+ async function onClose(): Promise<void> {
1161
+ // Ask the server to stop, then try to close the tab. window.close() only
1162
+ // works for script-opened windows, so we always fall back to the terminal
1163
+ // panel telling the user they can close it themselves.
1164
+ try {
1165
+ await shutdown()
1166
+ } catch {
1167
+ // Server may drop the socket as it exits — that's still a successful stop.
1168
+ }
1169
+ setStopped(true)
1170
+ window.close()
1171
+ }
1172
+
1173
+ if (stopped) {
1174
+ return (
1175
+ <Shell>
1176
+ <section className="as-section as-stopped" role="status">
1177
+ <h2 className="as-section-title">Server stopped</h2>
1178
+ <p className="as-section-hint">You can close this tab. AgentZ server stopped.</p>
1179
+ </section>
1180
+ </Shell>
1181
+ )
1182
+ }
1183
+
1184
+ return (
1185
+ <Tooltip.Provider>
1186
+ <Shell
1187
+ screen={screen}
1188
+ projects={projects}
1189
+ project={project}
1190
+ onProject={onSwitchProject}
1191
+ >
1192
+ {banner && (
1193
+ <div className="as-banner" role="status">
1194
+ <span className="as-ok">{banner}</span>
1195
+ {result && result.files.length > 0 && (
1196
+ <span className="as-banner-detail"> · {result.files.length} file(s) changed</span>
1197
+ )}
1198
+ <button className="as-btn" data-size="sm" onClick={onClose}>
1199
+ Close
1200
+ </button>
1201
+ <button className="as-banner-close" aria-label="dismiss" onClick={() => setBanner(null)}>
1202
+ ×
1203
+ </button>
1204
+ </div>
1205
+ )}
1206
+ {result && banner && <ResultDetails result={result} />}
1207
+ {screen === "current" && existing && (
1208
+ <CurrentInstall state={existing} onModify={onModify} />
1209
+ )}
1210
+ {screen === "setup" && (
1211
+ <Setup
1212
+ catalog={catalog}
1213
+ graph={graph}
1214
+ state={state}
1215
+ dispatch={dispatch}
1216
+ notice={setupNotice}
1217
+ onNext={() => setScreen("review")}
1218
+ />
1219
+ )}
1220
+ {screen === "review" && (
1221
+ <Review
1222
+ catalog={catalog}
1223
+ graph={graph}
1224
+ models={models}
1225
+ state={state}
1226
+ dispatch={dispatch}
1227
+ preview={preview}
1228
+ files={files}
1229
+ deletions={deletions}
1230
+ loading={loading}
1231
+ error={error ?? installError}
1232
+ onBack={() => setScreen(existing?.installed ? "current" : "setup")}
1233
+ onInstall={doInstall}
1234
+ />
1235
+ )}
1236
+ {screen === "installing" && <p className="as-spinner">installing…</p>}
1237
+ </Shell>
1238
+ </Tooltip.Provider>
1239
+ )
1240
+ }
1241
+
1242
+ function agentsMdValue(
1243
+ chosen: WizardState["agentsMd"],
1244
+ fallback: PreviewModel["agentsMd"] | undefined,
1245
+ ): string {
1246
+ const v = chosen ?? fallback ?? "lean"
1247
+ return v === false ? "off" : v
1248
+ }
1249
+
1250
+ function preloadState(catalog: CatalogResponse, st: StateResponse): WizardState {
1251
+ const base = defaultState(catalog)
1252
+ if (!st.installed) return base
1253
+ // Preset-based installs recover ALL their presets (multi-select preserved) so
1254
+ // the setup screen preselects every one and the user can toggle any of them.
1255
+ // Non-preset installs keep the default preset as a starting point; Modify
1256
+ // routes to setup with a notice, and the user picks presets from scratch.
1257
+ const presetNames =
1258
+ st.presetBased && st.presets && st.presets.length > 0
1259
+ ? st.presets
1260
+ : st.presetBased && st.preset
1261
+ ? [st.preset]
1262
+ : [base.presetNames[0]!]
1263
+ return {
1264
+ ...base,
1265
+ presetNames,
1266
+ modelPack: st.modelPack ?? base.modelPack,
1267
+ targets: (st.targets as WizardState["targets"] | undefined) ?? base.targets,
1268
+ scope: st.scope ?? base.scope,
1269
+ // Restore template vars from the on-disk config so per-tier model overrides
1270
+ // (model.cheap|normal|expert) and any flags survive a Modify/reinstall
1271
+ // instead of being reset to defaults. Defaults seed first, config wins.
1272
+ vars: st.vars && Object.keys(st.vars).length > 0 ? { ...base.vars, ...st.vars } : base.vars,
1273
+ // Restore existing per-agent overrides from the on-disk config so the
1274
+ // edge-case per-agent path (model/effort) survives a Modify/reinstall
1275
+ // round-trip instead of being dropped. Config wins over the empty default.
1276
+ agents: st.agents && Object.keys(st.agents).length > 0 ? st.agents : base.agents,
1277
+ // Restore per-tier thinking defaults from the on-disk config so the tier
1278
+ // reasoning-effort controls survive a Modify/reinstall round-trip.
1279
+ tierThinking:
1280
+ st.tierThinking && Object.keys(st.tierThinking).length > 0
1281
+ ? st.tierThinking
1282
+ : base.tierThinking,
1283
+ // Restore USER-ADDED extras from the lock so a subsequent sync-install does
1284
+ // not delete them (each `type:name` ref → its `extra` bucket). Without this
1285
+ // the wizard would resolve to preset closure only and prune every extra.
1286
+ extra: restoreExtras(st.extras),
1287
+ }
1288
+ }
1289
+
1290
+ /** Map lock `type:name` extra refs back into WizardState `extra` buckets. */
1291
+ function restoreExtras(refs: string[] | undefined): WizardState["extra"] {
1292
+ if (!refs || refs.length === 0) return undefined
1293
+ const buckets: Partial<Record<ExtraKind, string[]>> = {}
1294
+ const kinds: ReadonlySet<string> = new Set([
1295
+ "agent",
1296
+ "skill",
1297
+ "tool",
1298
+ "plugin",
1299
+ "mcp",
1300
+ "command",
1301
+ "rule",
1302
+ "lsp",
1303
+ ])
1304
+ for (const ref of refs) {
1305
+ const sep = ref.indexOf(":")
1306
+ if (sep < 0) continue
1307
+ const kind = ref.slice(0, sep)
1308
+ const name = ref.slice(sep + 1)
1309
+ if (!name || !kinds.has(kind)) continue
1310
+ ;(buckets[kind as ExtraKind] ??= []).push(name)
1311
+ }
1312
+ return Object.keys(buckets).length > 0 ? buckets : undefined
1313
+ }
1314
+
1315
+ /* ================================================================== */
1316
+ /* shell + step header */
1317
+ /* ================================================================== */
1318
+
1319
+ const STEP_LABELS: { id: Screen; label: string }[] = [
1320
+ { id: "setup", label: "Setup" },
1321
+ { id: "review", label: "Review" },
1322
+ { id: "done", label: "Install" },
1323
+ ]
1324
+
1325
+ function Shell(props: {
1326
+ children: React.ReactNode
1327
+ screen?: Screen
1328
+ projects?: ProjectInfo[]
1329
+ project?: string
1330
+ onProject?: (p: string) => void
1331
+ }): React.JSX.Element {
1332
+ const showSwitcher = (props.projects?.length ?? 0) > 1
1333
+ return (
1334
+ <div className="as-app">
1335
+ <header className="as-header">
1336
+ <h1 className="as-title">
1337
+ <img className="as-logo" src="/logo.png" alt="" />
1338
+ AgentZ
1339
+ </h1>
1340
+ {showSwitcher && props.onProject && (
1341
+ <label className="as-project-switch">
1342
+ <span className="as-label">project</span>
1343
+ <select
1344
+ className="as-select"
1345
+ value={props.project ?? ""}
1346
+ onChange={(e) => props.onProject!(e.target.value)}
1347
+ >
1348
+ {props.projects!.map((p) => (
1349
+ <option key={p.path} value={p.path}>
1350
+ {p.label}
1351
+ </option>
1352
+ ))}
1353
+ </select>
1354
+ </label>
1355
+ )}
1356
+ <ol className="as-steps">
1357
+ {STEP_LABELS.map((s) => (
1358
+ <li
1359
+ key={s.id}
1360
+ className="as-step"
1361
+ data-active={props.screen === s.id || (s.id === "done" && props.screen === "installing")}
1362
+ >
1363
+ {s.label}
1364
+ </li>
1365
+ ))}
1366
+ </ol>
1367
+ </header>
1368
+ {props.children}
1369
+ </div>
1370
+ )
1371
+ }
1372
+
1373
+ /* ================================================================== */
1374
+ /* current install summary */
1375
+ /* ================================================================== */
1376
+
1377
+ function CurrentInstall(props: { state: StateResponse; onModify: () => void }): React.JSX.Element {
1378
+ const s = props.state
1379
+ const groups = groupLockedRefs(s.lockedRefs ?? [])
1380
+ return (
1381
+ <section className="as-section">
1382
+ <h2 className="as-section-title">Current install</h2>
1383
+ <p className="as-section-hint">This project already has AgentZ configured.</p>
1384
+ <div className="as-panel">
1385
+ <dl className="as-summary-grid">
1386
+ {s.preset && (<><dt>preset</dt><dd>{s.preset}</dd></>)}
1387
+ {s.modelPack && (<><dt>model pack</dt><dd>{s.modelPack}</dd></>)}
1388
+ {s.targets && (<><dt>targets</dt><dd>{s.targets.join(", ")}</dd></>)}
1389
+ {s.scope && (<><dt>scope</dt><dd>{s.scope}</dd></>)}
1390
+ <dt>locked items</dt>
1391
+ <dd>{s.lockedItems ?? 0}</dd>
1392
+ </dl>
1393
+ </div>
1394
+ {groups.length > 0 && (
1395
+ <div className="as-panel">
1396
+ <div className="as-row-name">Installed items</div>
1397
+ <dl className="as-summary-grid">
1398
+ {groups.map((g) => (
1399
+ <Fragment key={g.type}>
1400
+ <dt>{g.type}</dt>
1401
+ <dd>{g.names.join(", ")}</dd>
1402
+ </Fragment>
1403
+ ))}
1404
+ </dl>
1405
+ </div>
1406
+ )}
1407
+ <div className="as-actions">
1408
+ <button className="as-btn" data-variant="primary" onClick={props.onModify}>
1409
+ Modify
1410
+ </button>
1411
+ </div>
1412
+ </section>
1413
+ )
1414
+ }
1415
+
1416
+ /** Plural, human-readable bucket label per lock ref `type`. */
1417
+ const REF_TYPE_LABELS: Record<string, string> = {
1418
+ agent: "agents",
1419
+ skill: "skills",
1420
+ tool: "tools",
1421
+ plugin: "plugins",
1422
+ command: "commands",
1423
+ mcp: "mcps",
1424
+ lsp: "language servers",
1425
+ preset: "presets",
1426
+ pack: "packs",
1427
+ }
1428
+
1429
+ /**
1430
+ * Group lock refs (`[@ns/]type:name`) by type into readable name lists, sorted
1431
+ * by a stable type order then name. So the current-install screen shows WHAT is
1432
+ * installed, not just a count.
1433
+ */
1434
+ function groupLockedRefs(refs: string[]): { type: string; names: string[] }[] {
1435
+ const buckets = new Map<string, Set<string>>()
1436
+ for (const ref of refs) {
1437
+ const m = /(?:^|\/)([a-z]+):(.+)$/.exec(ref)
1438
+ const rawType = m?.[1] ?? "other"
1439
+ const name = m?.[2] ?? ref
1440
+ const label = REF_TYPE_LABELS[rawType] ?? rawType
1441
+ if (!buckets.has(label)) buckets.set(label, new Set())
1442
+ buckets.get(label)!.add(name)
1443
+ }
1444
+ const order = Object.values(REF_TYPE_LABELS)
1445
+ return [...buckets.entries()]
1446
+ .map(([type, set]) => ({ type, names: [...set].sort() }))
1447
+ .sort((a, b) => {
1448
+ const ia = order.indexOf(a.type)
1449
+ const ib = order.indexOf(b.type)
1450
+ return (ia === -1 ? 99 : ia) - (ib === -1 ? 99 : ib) || a.type.localeCompare(b.type)
1451
+ })
1452
+ }
1453
+
1454
+ /* ================================================================== */
1455
+ /* setup picker */
1456
+ /* ================================================================== */
1457
+
1458
+ function Setup(props: {
1459
+ catalog: CatalogResponse
1460
+ graph: GraphInfo | null
1461
+ state: WizardState
1462
+ dispatch: React.Dispatch<Action>
1463
+ notice?: string | null
1464
+ onNext: () => void
1465
+ }): React.JSX.Element {
1466
+ const { catalog, graph, state, dispatch } = props
1467
+ return (
1468
+ <>
1469
+ {props.notice && <p className="as-notice">{props.notice}</p>}
1470
+ <section className="as-section">
1471
+ <h2 className="as-section-title">Choose presets</h2>
1472
+ <p className="as-section-hint">
1473
+ A preset is a curated set of agents, skills and tools. Select one or more.
1474
+ </p>
1475
+ <div className="as-grid">
1476
+ {catalog.presets.map((p) => (
1477
+ <SelectCard
1478
+ key={p.name}
1479
+ title={p.label}
1480
+ description={p.description}
1481
+ selected={state.presetNames.includes(p.name)}
1482
+ graph={graph}
1483
+ graphId={`preset:${p.name}`}
1484
+ onToggle={() => dispatch({ t: "preset", name: p.name })}
1485
+ />
1486
+ ))}
1487
+ </div>
1488
+ </section>
1489
+
1490
+ <section className="as-section">
1491
+ <h2 className="as-section-title">Model pack</h2>
1492
+ <p className="as-section-hint">Controls which models each agent tier uses.</p>
1493
+ <div className="as-grid">
1494
+ {catalog.packs.map((p) => (
1495
+ <SelectCard
1496
+ key={p.id}
1497
+ title={p.label}
1498
+ description={p.description}
1499
+ tiers={p.tiers}
1500
+ selected={state.modelPack === p.id}
1501
+ graph={graph}
1502
+ graphId={`pack:${p.id}`}
1503
+ onToggle={() => dispatch({ t: "pack", pack: p.id })}
1504
+ />
1505
+ ))}
1506
+ </div>
1507
+ </section>
1508
+
1509
+ <div className="as-actions">
1510
+ <button
1511
+ className="as-btn"
1512
+ data-variant="primary"
1513
+ disabled={state.presetNames.length === 0}
1514
+ onClick={props.onNext}
1515
+ >
1516
+ Review plan
1517
+ </button>
1518
+ </div>
1519
+ </>
1520
+ )
1521
+ }
1522
+
1523
+ /**
1524
+ * Selectable preset/pack card. Outer element is a plain container so the
1525
+ * graph-driven composition <details> can live inside as a sibling without
1526
+ * nested-interactive markup. Selection is a real full-width <button> (native
1527
+ * Enter/Space, no key-handler hacks); the <details> composition sits beside it.
1528
+ * Check indicator space is always reserved -> zero shift on select.
1529
+ */
1530
+ function SelectCard(props: {
1531
+ title: string
1532
+ description: string
1533
+ tiers?: CatalogPackTier[]
1534
+ selected: boolean
1535
+ graph: GraphInfo | null
1536
+ graphId: string
1537
+ onToggle: () => void
1538
+ }): React.JSX.Element {
1539
+ const counts = props.graph ? memberCounts(props.graph, props.graphId) : null
1540
+ const members = props.graph?.members.get(props.graphId) ?? []
1541
+ return (
1542
+ <div className="as-card" data-selected={props.selected}>
1543
+ <button
1544
+ type="button"
1545
+ className="as-card-select"
1546
+ aria-pressed={props.selected}
1547
+ onClick={props.onToggle}
1548
+ >
1549
+ <span className="as-card-check" aria-hidden="true">
1550
+ <CheckIcon />
1551
+ </span>
1552
+ <span className="as-card-title">{props.title}</span>
1553
+ <span className="as-card-desc">{props.description}</span>
1554
+ {props.tiers && props.tiers.length > 0 && (
1555
+ <span className="as-card-tiers">
1556
+ {props.tiers.map((t) => (
1557
+ <span key={t.tier} className="as-tier-chip">
1558
+ <span className="as-tier-name">{t.tier}</span>
1559
+ <span className="as-tier-model">{t.model}</span>
1560
+ {t.effort && <span className="as-tier-effort">@{t.effort}</span>}
1561
+ </span>
1562
+ ))}
1563
+ </span>
1564
+ )}
1565
+ {counts && <span className="as-card-meta">{counts}</span>}
1566
+ </button>
1567
+ {members.length > 0 && (
1568
+ <details className="as-card-comp">
1569
+ <summary>what this pulls in</summary>
1570
+ <ul>
1571
+ {members.map((id) => (
1572
+ <li key={id}>
1573
+ <span className="as-dep-kind">{id.split(":")[0]}</span>
1574
+ {props.graph?.labels.get(id) ?? id.split(":").pop()}
1575
+ </li>
1576
+ ))}
1577
+ </ul>
1578
+ </details>
1579
+ )}
1580
+ </div>
1581
+ )
1582
+ }
1583
+
1584
+ /* ================================================================== */
1585
+ /* review screen (app shell: main column + sticky rail) */
1586
+ /* ================================================================== */
1587
+
1588
+ function Review(props: {
1589
+ catalog: CatalogResponse
1590
+ graph: GraphInfo | null
1591
+ models: ModelsResponse | null
1592
+ state: WizardState
1593
+ dispatch: React.Dispatch<Action>
1594
+ preview: PreviewModel | null
1595
+ files: PreviewFiles | null
1596
+ deletions: PreviewResponse["deletions"]
1597
+ loading: boolean
1598
+ error: string | null
1599
+ onBack: () => void
1600
+ onInstall: () => void
1601
+ }): React.JSX.Element {
1602
+ const { catalog, graph, models, state, dispatch, preview, files, deletions } = props
1603
+ const primary = catalog.targets.find((t) => t.id === "opencode")
1604
+ const others = catalog.targets.filter((t) => t.id !== "opencode")
1605
+ const [sheetOpen, setSheetOpen] = useState(false)
1606
+ const [sheetTab, setSheetTab] = useState<"opencode" | "agentsmd">("opencode")
1607
+ const [delOpen, setDelOpen] = useState(false)
1608
+ // The "Override" switch controls VISIBILITY only of every agent
1609
+ // row's per-agent model/effort controls (the EDGE-CASE override path; the
1610
+ // primary path is the per-tier ModelPickers in "Targets & options"). It never
1611
+ // mutates overrides: toggling OFF hides default rows' controls but rows that
1612
+ // already carry an override keep showing theirs (AgentRow OR-s `custom` in) so
1613
+ // no active override becomes hidden. Clearing an override is the per-row reset
1614
+ // control's explicit job. Seeded OFF always: even a pre-existing per-agent
1615
+ // override must not auto-open anything (rows with an override still show their
1616
+ // own controls via AgentRow's `custom` OR, so no active override is hidden).
1617
+ const [showOverride, setShowOverride] = useState(false)
1618
+
1619
+ // Enabled item set (plan items minus exclusions) -> dependency-locked set.
1620
+ // Locked items keep their Switch ON and read-only until every enabled
1621
+ // dependent is disabled; graph==null keeps this map empty (no locks).
1622
+ const locks = useMemo(() => {
1623
+ const enabled = new Set<string>()
1624
+ for (const a of preview?.agents ?? []) enabled.add(`agent:${a.id}`)
1625
+ for (const s of preview?.skills ?? []) if (!state.excluded.skill.includes(s.id)) enabled.add(`skill:${s.id}`)
1626
+ for (const t of preview?.tools ?? []) if (!state.excluded.tool.includes(t.id)) enabled.add(`tool:${t.id}`)
1627
+ for (const p of preview?.plugins ?? []) if (!state.excluded.plugin.includes(p.id)) enabled.add(`plugin:${p.id}`)
1628
+ for (const c of preview?.commands ?? []) enabled.add(`command:${c.id}`)
1629
+ for (const r of preview?.rules ?? []) if (!state.excluded.rule.includes(r.id)) enabled.add(`rule:${r.id}`)
1630
+ for (const m of preview?.mcps ?? []) if (!state.excluded.mcp.includes(m.id)) enabled.add(`mcp:${m.id}`)
1631
+ for (const l of preview?.lsps ?? []) if (!state.excluded.lsp.includes(l.id)) enabled.add(`lsp:${l.id}`)
1632
+ return computeLocks(graph, enabled)
1633
+ }, [graph, preview, state.excluded])
1634
+
1635
+ // Reconcile: a dependency-locked item may still sit in state.excluded (it was
1636
+ // toggled off before something started requiring it). ItemSwitch renders it
1637
+ // forced-ON, but the install/preview payload would still exclude it. Re-enable
1638
+ // any locked id whose name is in state.excluded so UI and state agree. Guarded:
1639
+ // only dispatch when actually excluded, so no toggle→re-render→dispatch loop.
1640
+ useEffect(() => {
1641
+ for (const id of locks.keys()) {
1642
+ const sep = id.indexOf(":")
1643
+ const kind = id.slice(0, sep) as keyof WizardState["excluded"]
1644
+ const name = id.slice(sep + 1)
1645
+ if (state.excluded[kind]?.includes(name)) {
1646
+ dispatch({ t: "toggle", kind, name, on: true })
1647
+ }
1648
+ }
1649
+ }, [locks, state.excluded, dispatch])
1650
+
1651
+ function openSheet(tab: "opencode" | "agentsmd"): void {
1652
+ setSheetTab(tab)
1653
+ setSheetOpen(true)
1654
+ }
1655
+
1656
+ const itemCounts = preview
1657
+ ? [
1658
+ { label: "agents", n: preview.agents.length },
1659
+ { label: "skills", n: preview.skills.filter((s) => !state.excluded.skill.includes(s.id)).length },
1660
+ { label: "tools", n: preview.tools.filter((t) => !state.excluded.tool.includes(t.id)).length },
1661
+ { label: "plugins", n: preview.plugins.filter((p) => !state.excluded.plugin.includes(p.id)).length },
1662
+ { label: "commands", n: preview.commands.length },
1663
+ { label: "rules", n: preview.rules.filter((r) => !state.excluded.rule.includes(r.id)).length },
1664
+ { label: "mcps", n: preview.mcps.filter((m) => !state.excluded.mcp.includes(m.id)).length },
1665
+ { label: "language servers", n: preview.lsps.filter((l) => !state.excluded.lsp.includes(l.id)).length },
1666
+ ].filter((c) => c.n > 0)
1667
+ : []
1668
+
1669
+ return (
1670
+ <div className="as-review">
1671
+ <div className="as-review-main">
1672
+ {props.error && <p className="as-error">{props.error}</p>}
1673
+
1674
+ {/* targets + scope + AGENTS.md */}
1675
+ <section className="as-section">
1676
+ <div className="as-section-head">
1677
+ <h2 className="as-section-title">Targets & options</h2>
1678
+ </div>
1679
+ <div className="as-panel">
1680
+ <div className="as-row">
1681
+ <div className="as-row-main">
1682
+ <div className="as-row-name">Frameworks</div>
1683
+ <div className="as-row-desc">Install into these agent frameworks.</div>
1684
+ </div>
1685
+ </div>
1686
+ {primary && <TargetRow target={primary} state={state} dispatch={dispatch} />}
1687
+ {others.length > 0 && (
1688
+ <Collapsible.Root className="as-collapse" data-variant="warn">
1689
+ <Collapsible.Trigger className="as-collapse-trigger">
1690
+ Other harnesses (experimental)
1691
+ </Collapsible.Trigger>
1692
+ <Collapsible.Panel className="as-collapse-panel">
1693
+ <p className="as-warn">
1694
+ Reduced support: thinking/reasoning effort, plugins and commands are dropped for
1695
+ these targets. Enable only as a deliberate experimental choice.
1696
+ </p>
1697
+ {others.map((t) => (
1698
+ <TargetRow key={t.id} target={t} state={state} dispatch={dispatch} />
1699
+ ))}
1700
+ </Collapsible.Panel>
1701
+ </Collapsible.Root>
1702
+ )}
1703
+ <div className="as-row as-row-controls">
1704
+ <div className="as-field">
1705
+ <span className="as-label" id="as-scope-label">Scope</span>
1706
+ <Seg
1707
+ ariaLabel="scope"
1708
+ value={state.scope}
1709
+ onChange={(v) => dispatch({ t: "scope", scope: v as "project" | "user" })}
1710
+ options={[
1711
+ { value: "project", label: "project" },
1712
+ { value: "user", label: "user" },
1713
+ ]}
1714
+ />
1715
+ </div>
1716
+ <div className="as-field">
1717
+ <span className="as-label" id="as-agentsmd-label">AGENTS.md</span>
1718
+ <Seg
1719
+ ariaLabel="AGENTS.md mode"
1720
+ value={agentsMdValue(state.agentsMd, preview?.agentsMd)}
1721
+ onChange={(v) => dispatch({ t: "agentsMd", mode: v })}
1722
+ options={catalog.agentsModes.map((m) => ({ value: m.value, label: m.label }))}
1723
+ />
1724
+ </div>
1725
+ </div>
1726
+ {/* Model tier overrides: compact nested collapsible, collapsed by
1727
+ default. The full trigger row owns the "Model overrides" title;
1728
+ TierOverrides renders only description + fields inside the panel.
1729
+ State (any set tier model/effort) survives collapse untouched. */}
1730
+ <Collapsible.Root className="as-collapse as-tier-collapse">
1731
+ <Collapsible.Trigger className="as-collapse-trigger as-tier-collapse-trigger">
1732
+ <span className="as-tier-collapse-title">Model overrides</span>
1733
+ <Chevron />
1734
+ </Collapsible.Trigger>
1735
+ <Collapsible.Panel className="as-collapse-panel as-section-panel">
1736
+ <TierOverrides catalog={catalog} models={models} state={state} dispatch={dispatch} />
1737
+ </Collapsible.Panel>
1738
+ </Collapsible.Root>
1739
+ </div>
1740
+ </section>
1741
+
1742
+ {/* agents: per-agent model/effort overrides (edge-case path; the
1743
+ primary override path is the tier ModelPickers above). Collapsible
1744
+ like its sibling sections, collapsed by default. */}
1745
+ <CollapsibleSection
1746
+ title="Agents"
1747
+ enabled={(preview?.agents ?? []).length}
1748
+ total={(preview?.agents ?? []).length}
1749
+ addSlot={
1750
+ <div className="as-section-head-actions">
1751
+ <div className="as-field as-override-toggle">
1752
+ <span className="as-label" id="as-override-label">Override</span>
1753
+ <ItemSwitch
1754
+ checked={showOverride}
1755
+ label="Show per-agent model override controls"
1756
+ onChange={setShowOverride}
1757
+ />
1758
+ </div>
1759
+ <AddPopover
1760
+ kind="agent"
1761
+ label="Agents"
1762
+ items={catalog.agents}
1763
+ added={state.extra?.agent ?? []}
1764
+ present={(preview?.agents ?? []).map((a) => a.id)}
1765
+ graph={graph}
1766
+ dispatch={dispatch}
1767
+ />
1768
+ </div>
1769
+ }
1770
+ >
1771
+ {models && models.available && !models.connected && (
1772
+ <p className="as-warn">Showing full models.dev catalog: no opencode providers detected.</p>
1773
+ )}
1774
+ <div className="as-panel">
1775
+ {(preview?.agents ?? []).map((agent) => (
1776
+ <AgentRow
1777
+ key={agent.id}
1778
+ agent={agent}
1779
+ catalog={catalog}
1780
+ models={models}
1781
+ state={state}
1782
+ graph={graph}
1783
+ showOverride={showOverride}
1784
+ dispatch={dispatch}
1785
+ />
1786
+ ))}
1787
+ {!preview && <p className="as-spinner">resolving plan…</p>}
1788
+ </div>
1789
+ </CollapsibleSection>
1790
+
1791
+ <ToggleSection
1792
+ title="Skills"
1793
+ kind="skill"
1794
+ items={preview?.skills ?? []}
1795
+ excluded={state.excluded.skill}
1796
+ catalogItems={catalog.skills}
1797
+ graph={graph}
1798
+ locks={locks}
1799
+ state={state}
1800
+ dispatch={dispatch}
1801
+ />
1802
+ <ToggleSection
1803
+ title="Tools"
1804
+ kind="tool"
1805
+ items={preview?.tools ?? []}
1806
+ excluded={state.excluded.tool}
1807
+ catalogItems={catalog.tools}
1808
+ graph={graph}
1809
+ locks={locks}
1810
+ state={state}
1811
+ dispatch={dispatch}
1812
+ />
1813
+ <ToggleSection
1814
+ title="Plugins"
1815
+ kind="plugin"
1816
+ items={preview?.plugins ?? []}
1817
+ excluded={state.excluded.plugin}
1818
+ catalogItems={catalog.plugins}
1819
+ graph={graph}
1820
+ locks={locks}
1821
+ state={state}
1822
+ dispatch={dispatch}
1823
+ />
1824
+ <ToggleSection
1825
+ title="Commands"
1826
+ kind="command"
1827
+ items={(preview?.commands ?? []).map((c) => ({ id: c.id, label: c.label }))}
1828
+ excluded={[]}
1829
+ catalogItems={catalog.commands}
1830
+ graph={graph}
1831
+ locks={locks}
1832
+ state={state}
1833
+ dispatch={dispatch}
1834
+ readOnly
1835
+ />
1836
+ <ToggleSection
1837
+ title="Rules"
1838
+ kind="rule"
1839
+ items={preview?.rules ?? []}
1840
+ excluded={state.excluded.rule}
1841
+ catalogItems={catalog.rules}
1842
+ graph={graph}
1843
+ locks={locks}
1844
+ state={state}
1845
+ dispatch={dispatch}
1846
+ />
1847
+
1848
+ {/* MCPs + their options */}
1849
+ <CollapsibleSection
1850
+ title="MCP servers"
1851
+ total={(preview?.mcps ?? []).length}
1852
+ enabled={(preview?.mcps ?? []).filter((m) => !state.excluded.mcp.includes(m.id)).length}
1853
+ addSlot={
1854
+ <AddPopover
1855
+ kind="mcp"
1856
+ label="MCP servers"
1857
+ items={catalog.mcps.map((m) => ({ name: m.name, label: m.label, description: m.description }))}
1858
+ added={state.extra?.mcp ?? []}
1859
+ present={(preview?.mcps ?? []).map((m) => m.id)}
1860
+ graph={graph}
1861
+ dispatch={dispatch}
1862
+ />
1863
+ }
1864
+ >
1865
+ <div className="as-panel">
1866
+ {(preview?.mcps ?? []).map((mcp) => {
1867
+ const catMcp = catalog.mcps.find((m) => m.name === mcp.id)
1868
+ const excluded = state.excluded.mcp.includes(mcp.id)
1869
+ const lockedBy = locks.get(`mcp:${mcp.id}`)
1870
+ const isExtra = (state.extra?.mcp ?? []).includes(mcp.id)
1871
+ const lockNoteId = `lock-mcp-${mcp.id}`
1872
+ return (
1873
+ <div key={mcp.id}>
1874
+ <div className="as-row">
1875
+ <ItemSwitch
1876
+ checked={!excluded}
1877
+ locked={Boolean(lockedBy)}
1878
+ label={mcp.label}
1879
+ describedById={lockNoteId}
1880
+ onChange={(on) => {
1881
+ // Same extra/switch parity as ToggleSection: an added
1882
+ // MCP's switch OFF removes the extra (recoverable via
1883
+ // "+ Add") rather than orphaning it in `excluded`.
1884
+ if (!on && isExtra) {
1885
+ dispatch({ t: "extra", kind: "mcp", name: mcp.id, on: false })
1886
+ return
1887
+ }
1888
+ dispatch({ t: "toggle", kind: "mcp", name: mcp.id, on })
1889
+ }}
1890
+ />
1891
+ <div className="as-row-main">
1892
+ <span className="as-row-name" title={catMcp?.description || undefined}>
1893
+ {mcp.label}
1894
+ {isExtra && <span className="as-tag">added</span>}
1895
+ </span>
1896
+ <div className="as-row-detail">{mcp.detail}</div>
1897
+ {lockedBy && <LockNote requirers={lockedBy} id={lockNoteId} />}
1898
+ </div>
1899
+ {isExtra && (
1900
+ <button
1901
+ type="button"
1902
+ className="as-remove"
1903
+ aria-label={`remove ${mcp.label}`}
1904
+ onClick={() => dispatch({ t: "extra", kind: "mcp", name: mcp.id, on: false })}
1905
+ >
1906
+ ×
1907
+ </button>
1908
+ )}
1909
+ </div>
1910
+ {!excluded &&
1911
+ catMcp?.options.map((opt) => (
1912
+ <div className="as-row as-row-nested" key={opt.id}>
1913
+ <ItemSwitch
1914
+ checked={state.mcps[mcp.id]?.options?.[opt.id] ?? opt.default}
1915
+ label={`${mcp.label}: ${opt.label}`}
1916
+ onChange={(on) => dispatch({ t: "mcpOption", mcp: mcp.id, option: opt.id, on })}
1917
+ />
1918
+ <div className="as-row-main">
1919
+ <span className="as-row-name">{opt.label}</span>
1920
+ {opt.description && <span className="as-row-desc"> · {opt.description}</span>}
1921
+ </div>
1922
+ </div>
1923
+ ))}
1924
+ </div>
1925
+ )
1926
+ })}
1927
+ {(preview?.mcps ?? []).length === 0 && <p className="as-row-desc">none</p>}
1928
+ </div>
1929
+ </CollapsibleSection>
1930
+
1931
+ <ToggleSection
1932
+ title="Language servers"
1933
+ kind="lsp"
1934
+ items={preview?.lsps ?? []}
1935
+ excluded={state.excluded.lsp}
1936
+ catalogItems={catalog.lsps}
1937
+ graph={graph}
1938
+ locks={locks}
1939
+ state={state}
1940
+ dispatch={dispatch}
1941
+ />
1942
+ </div>
1943
+
1944
+ {/* sticky right rail: summary + previews + install */}
1945
+ <aside className="as-rail" aria-label="setup summary">
1946
+ <div className="as-panel as-rail-panel">
1947
+ <h2 className="as-section-title">Summary</h2>
1948
+ <dl className="as-summary-grid as-rail-grid">
1949
+ <dt>presets</dt>
1950
+ <dd>{state.presetNames.join(", ") || "-"}</dd>
1951
+ <dt>pack</dt>
1952
+ <dd>{state.modelPack}</dd>
1953
+ <dt>targets</dt>
1954
+ <dd>{state.targets.join(", ") || "-"}</dd>
1955
+ <dt>scope</dt>
1956
+ <dd>{state.scope}</dd>
1957
+ </dl>
1958
+ {itemCounts.length > 0 && (
1959
+ <p className="as-rail-counts">
1960
+ {itemCounts.map((c) => `${c.n} ${c.label}`).join(" · ")}
1961
+ </p>
1962
+ )}
1963
+ {deletions.length > 0 && (
1964
+ <p className="as-rail-counts as-rail-deletions">
1965
+ {deletions.length} file(s) to delete
1966
+ </p>
1967
+ )}
1968
+ <div className="as-rail-previews">
1969
+ <span className="as-label">Preview files</span>
1970
+ <button type="button" className="as-btn" data-size="sm" onClick={() => openSheet("opencode")}>
1971
+ opencode.json
1972
+ </button>
1973
+ <button type="button" className="as-btn" data-size="sm" onClick={() => openSheet("agentsmd")}>
1974
+ AGENTS.md
1975
+ </button>
1976
+ {deletions.length > 0 && (
1977
+ <button
1978
+ type="button"
1979
+ className="as-btn"
1980
+ data-size="sm"
1981
+ data-variant="danger"
1982
+ onClick={() => setDelOpen(true)}
1983
+ >
1984
+ Deleted files ({deletions.length})
1985
+ </button>
1986
+ )}
1987
+ </div>
1988
+ <div className="as-rail-actions">
1989
+ {props.error && <p className="as-error as-rail-error">{props.error}</p>}
1990
+ <button className="as-btn" onClick={props.onBack}>Back</button>
1991
+ <button
1992
+ className="as-btn"
1993
+ data-variant="primary"
1994
+ disabled={!preview || props.loading || props.error != null || state.targets.length === 0}
1995
+ onClick={props.onInstall}
1996
+ >
1997
+ {props.loading ? "updating…" : "Install"}
1998
+ </button>
1999
+ </div>
2000
+ </div>
2001
+ </aside>
2002
+
2003
+ <PreviewSheet
2004
+ open={sheetOpen}
2005
+ onOpenChange={setSheetOpen}
2006
+ tab={sheetTab}
2007
+ onTab={setSheetTab}
2008
+ files={files}
2009
+ loading={props.loading}
2010
+ />
2011
+
2012
+ <DeletionsSheet open={delOpen} onOpenChange={setDelOpen} deletions={deletions} />
2013
+ </div>
2014
+ )
2015
+ }
2016
+
2017
+ /**
2018
+ * Deletion preview sheet: lists the project-relative paths a sync-install would
2019
+ * remove (deselected items whose lock refs left the resolution closure). Only
2020
+ * opened when the preview reported a non-empty `deletions` set.
2021
+ */
2022
+ function DeletionsSheet(props: {
2023
+ open: boolean
2024
+ onOpenChange: (open: boolean) => void
2025
+ deletions: PreviewResponse["deletions"]
2026
+ }): React.JSX.Element {
2027
+ return (
2028
+ <Dialog.Root open={props.open} onOpenChange={props.onOpenChange}>
2029
+ <Dialog.Portal>
2030
+ <Dialog.Backdrop className="as-sheet-backdrop" />
2031
+ <Dialog.Popup className="as-sheet">
2032
+ <div className="as-sheet-head">
2033
+ <Dialog.Title className="as-sheet-title">
2034
+ Files to delete
2035
+ <span className="as-preview-status">{props.deletions.length} file(s)</span>
2036
+ </Dialog.Title>
2037
+ <Dialog.Close className="as-banner-close" aria-label="close deletions">
2038
+ ×
2039
+ </Dialog.Close>
2040
+ </div>
2041
+ <p className="as-section-hint">
2042
+ Installing this selection removes the following files (deselected items no longer in
2043
+ the resolution closure).
2044
+ </p>
2045
+ <ul className="as-filelist">
2046
+ {props.deletions.map((d, i) => (
2047
+ <li key={i}>
2048
+ <span className="as-file-status as-file-removed">removed</span>
2049
+ {d.path}
2050
+ </li>
2051
+ ))}
2052
+ </ul>
2053
+ </Dialog.Popup>
2054
+ </Dialog.Portal>
2055
+ </Dialog.Root>
2056
+ )
2057
+ }
2058
+
2059
+ /** A single framework target switch row. */
2060
+ function TargetRow(props: {
2061
+ target: { id: string; label: string; detected: boolean }
2062
+ state: WizardState
2063
+ dispatch: React.Dispatch<Action>
2064
+ }): React.JSX.Element {
2065
+ const { target: t, state, dispatch } = props
2066
+ return (
2067
+ <div className="as-row">
2068
+ <ItemSwitch
2069
+ checked={(state.targets as string[]).includes(t.id)}
2070
+ label={t.label}
2071
+ onChange={(on) =>
2072
+ dispatch({
2073
+ t: "targets",
2074
+ targets: on ? [...state.targets, t.id] : state.targets.filter((x) => x !== t.id),
2075
+ })
2076
+ }
2077
+ />
2078
+ <div className="as-row-main">
2079
+ <span className="as-row-name">{t.label}</span>
2080
+ {t.detected && <span className="as-row-desc"> · detected</span>}
2081
+ </div>
2082
+ </div>
2083
+ )
2084
+ }
2085
+
2086
+ /**
2087
+ * One agent row: resolved model as a hue badge (`provider/model : effort`)
2088
+ * tagged "default" (the resolved tier model) or "custom" (user override, with a
2089
+ * tooltip'd reset control). When the section's global "Override defaults"
2090
+ * switch is ON the searchable model combobox and the reasoning-effort segmented
2091
+ * control are shown; clearing the fields resets to the resolved tier model
2092
+ * (which may itself be a user tier override), the reducer drops the per-agent
2093
+ * override, and the badge flips back to DEFAULT.
2094
+ */
2095
+ function AgentRow(props: {
2096
+ agent: { id: string; label: string; model: string; reasoningEffort?: string }
2097
+ catalog: CatalogResponse
2098
+ models: ModelsResponse | null
2099
+ state: WizardState
2100
+ graph: GraphInfo | null
2101
+ showOverride: boolean
2102
+ dispatch: React.Dispatch<Action>
2103
+ }): React.JSX.Element {
2104
+ const { agent, catalog, models, state, graph, showOverride, dispatch } = props
2105
+ const override = typeof state.agents[agent.id]?.model === "string" ? (state.agents[agent.id]!.model as string) : ""
2106
+ const effort = state.agents[agent.id]?.thinking?.reasoningEffort
2107
+ const custom = override !== "" || effort !== undefined
2108
+ // Show the model/effort controls when the global toggle is ON, OR when this
2109
+ // row already carries an override — so turning the toggle OFF never hides an
2110
+ // active override (it only collapses default rows). Per-row reset stays the
2111
+ // explicit clearing path.
2112
+ const controlsVisible = showOverride || custom
2113
+ // Badge effort: the user's override effort when set, otherwise the resolved
2114
+ // effort the preview reports for the tier model (may be absent → no suffix,
2115
+ // never invented).
2116
+ const badgeEffort = effort ?? agent.reasoningEffort
2117
+ const isExtra = (state.extra?.agent ?? []).includes(agent.id)
2118
+ const agentDesc = catalog.agents.find((a) => a.name === agent.id)?.description
2119
+ function reset(): void {
2120
+ dispatch({ t: "agentModel", agentId: agent.id, model: "" })
2121
+ dispatch({ t: "agentEffort", agentId: agent.id, effort: "" })
2122
+ }
2123
+ return (
2124
+ <div className="as-row as-agent-row">
2125
+ <div className="as-row-main">
2126
+ <span className="as-row-name" title={agentDesc || undefined}>
2127
+ {agent.label}
2128
+ {isExtra && <span className="as-tag">added</span>}
2129
+ </span>
2130
+ <div className="as-row-detail as-agent-model">
2131
+ <ModelBadge model={agent.model} effort={badgeEffort} />
2132
+ <span className="as-tag" data-variant={custom ? "custom" : "default"}>
2133
+ {custom ? "custom" : "default"}
2134
+ </span>
2135
+ {custom && (
2136
+ <Tooltip.Root>
2137
+ <Tooltip.Trigger
2138
+ render={
2139
+ <button type="button" className="as-remove" aria-label={`reset ${agent.label} to tier model`} onClick={reset} />
2140
+ }
2141
+ >
2142
+
2143
+ </Tooltip.Trigger>
2144
+ <Tooltip.Portal>
2145
+ <Tooltip.Positioner className="as-positioner" sideOffset={6}>
2146
+ <Tooltip.Popup className="as-tooltip">Reset to tier model</Tooltip.Popup>
2147
+ </Tooltip.Positioner>
2148
+ </Tooltip.Portal>
2149
+ </Tooltip.Root>
2150
+ )}
2151
+ </div>
2152
+ <DepChips info={graph} kind="agent" name={agent.id} />
2153
+ </div>
2154
+ {controlsVisible && (
2155
+ <div className="as-agent-controls">
2156
+ <div className="as-field">
2157
+ <label className="as-label" htmlFor={`model-${agent.id}`}>model override</label>
2158
+ <ModelPicker
2159
+ id={`model-${agent.id}`}
2160
+ models={models}
2161
+ placeholder={agent.model}
2162
+ value={override}
2163
+ onChange={(v) => dispatch({ t: "agentModel", agentId: agent.id, model: v })}
2164
+ />
2165
+ </div>
2166
+ <div className="as-field">
2167
+ <span className="as-label">effort</span>
2168
+ <Seg
2169
+ ariaLabel={`${agent.label} reasoning effort`}
2170
+ value={effort ?? ""}
2171
+ onChange={(v) => dispatch({ t: "agentEffort", agentId: agent.id, effort: v })}
2172
+ options={[
2173
+ { value: "", label: "auto" },
2174
+ ...catalog.reasoningEfforts.map((r) => ({ value: r, label: r })),
2175
+ ]}
2176
+ />
2177
+ </div>
2178
+ </div>
2179
+ )}
2180
+ {isExtra && (
2181
+ <button
2182
+ type="button"
2183
+ className="as-remove"
2184
+ aria-label={`remove ${agent.label}`}
2185
+ onClick={() => dispatch({ t: "extra", kind: "agent", name: agent.id, on: false })}
2186
+ >
2187
+ ×
2188
+ </button>
2189
+ )}
2190
+ </div>
2191
+ )
2192
+ }
2193
+
2194
+ /** Chevron that rotates with the collapsible open state (CSS drives rotation). */
2195
+ function Chevron(): React.JSX.Element {
2196
+ return (
2197
+ <svg className="as-chevron" width="14" height="14" viewBox="0 0 12 12" aria-hidden="true" focusable="false">
2198
+ <path d="M3 4.5 L6 7.5 L9 4.5" stroke="currentColor" strokeWidth="1.9" fill="none" strokeLinecap="round" strokeLinejoin="round" />
2199
+ </svg>
2200
+ )
2201
+ }
2202
+
2203
+ /**
2204
+ * A review section that starts COLLAPSED: the header row (title + "enabled/
2205
+ * total" count badge + optional "+ Add" slot + chevron) is always visible and
2206
+ * the body animates open/closed via Base UI Collapsible (140ms height/opacity).
2207
+ * The "+ Add" slot lives OUTSIDE the trigger so it stays clickable while the
2208
+ * section is collapsed (its popover is a portal overlay, so it does not expand
2209
+ * the section — the count still reflects any additions live).
2210
+ */
2211
+ function CollapsibleSection(props: {
2212
+ title: string
2213
+ enabled: number
2214
+ total: number
2215
+ defaultOpen?: boolean
2216
+ addSlot?: React.ReactNode
2217
+ children: React.ReactNode
2218
+ }): React.JSX.Element {
2219
+ return (
2220
+ <Collapsible.Root className="as-section as-section-collapsible" defaultOpen={props.defaultOpen ?? false}>
2221
+ <div className="as-section-head as-collapse-head">
2222
+ <Collapsible.Trigger className="as-collapse-head-trigger">
2223
+ <h2 className="as-section-title">{props.title}</h2>
2224
+ <span className="as-count as-section-count">
2225
+ {props.enabled}/{props.total}
2226
+ </span>
2227
+ <Chevron />
2228
+ </Collapsible.Trigger>
2229
+ {props.addSlot}
2230
+ </div>
2231
+ <Collapsible.Panel className="as-collapse-panel as-section-panel">
2232
+ {props.children}
2233
+ </Collapsible.Panel>
2234
+ </Collapsible.Root>
2235
+ )
2236
+ }
2237
+
2238
+ /**
2239
+ * A toggle section card (Skills/Tools/Plugins/Commands): switch rows with
2240
+ * dependency locks, an "+ Add" popover in the header, and remove affordances
2241
+ * on rows the user added beyond the presets.
2242
+ */
2243
+ function ToggleSection(props: {
2244
+ title: string
2245
+ kind: keyof WizardState["excluded"] | "command"
2246
+ items: { id: string; label: string; contradicts?: string[] }[]
2247
+ excluded: string[]
2248
+ catalogItems: { name: string; label: string; description: string }[]
2249
+ graph: GraphInfo | null
2250
+ locks: Map<string, string[]>
2251
+ state: WizardState
2252
+ dispatch: React.Dispatch<Action>
2253
+ readOnly?: boolean
2254
+ }): React.JSX.Element {
2255
+ const extras = props.state.extra?.[props.kind as ExtraKind] ?? []
2256
+ // Bare item name -> catalog description, for the hover title on each row.
2257
+ const descOf = new Map(props.catalogItems.map((c) => [c.name, c.description]))
2258
+ const total = props.items.length
2259
+ const enabled = props.readOnly
2260
+ ? total
2261
+ : props.items.filter((i) => !props.excluded.includes(i.id)).length
2262
+ // Active contradictions: an item's declared conflict only "fires" when BOTH
2263
+ // it and the named peer are currently selected (not excluded). Advisory —
2264
+ // selection stays allowed; we just surface the conflict in amber.
2265
+ const isSelected = (id: string) => props.readOnly || !props.excluded.includes(id)
2266
+ const activeConflicts = (item: { id: string; contradicts?: string[] }): string[] =>
2267
+ isSelected(item.id) ? (item.contradicts ?? []).filter(isSelected) : []
2268
+ const conflictPairs = new Set<string>()
2269
+ for (const it of props.items)
2270
+ for (const other of activeConflicts(it)) conflictPairs.add([it.id, other].sort().join(" ↔ "))
2271
+ return (
2272
+ <CollapsibleSection
2273
+ title={props.title}
2274
+ enabled={enabled}
2275
+ total={total}
2276
+ addSlot={
2277
+ <AddPopover
2278
+ kind={props.kind as ExtraKind}
2279
+ label={props.title}
2280
+ items={props.catalogItems}
2281
+ added={extras}
2282
+ present={props.items.map((i) => i.id)}
2283
+ graph={props.graph}
2284
+ dispatch={props.dispatch}
2285
+ />
2286
+ }
2287
+ >
2288
+ <div className="as-panel">
2289
+ {conflictPairs.size > 0 && (
2290
+ <p className="as-warn as-contradict-summary">
2291
+ Conflicting rules selected: {[...conflictPairs].join(", ")}. Both stay installable — consider keeping one.
2292
+ </p>
2293
+ )}
2294
+ {props.items.map((item) => {
2295
+ const lockedBy = props.locks.get(`${props.kind}:${item.id}`)
2296
+ const isExtra = extras.includes(item.id)
2297
+ const lockNoteId = `lock-${props.kind}-${item.id}`
2298
+ const conflicts = activeConflicts(item)
2299
+ return (
2300
+ <div className="as-row" key={item.id}>
2301
+ <ItemSwitch
2302
+ checked={!props.excluded.includes(item.id)}
2303
+ locked={Boolean(lockedBy) && !props.readOnly}
2304
+ disabled={props.readOnly}
2305
+ label={item.label}
2306
+ describedById={lockNoteId}
2307
+ onChange={(on) => {
2308
+ if (props.readOnly) return
2309
+ // An extra item's switch mirrors its × remove: turning it OFF
2310
+ // deletes the extra (recoverable via "+ Add") instead of
2311
+ // pushing it into `excluded`, which would drop its preview row
2312
+ // AND keep it in `extra` — leaving it invisible everywhere.
2313
+ if (!on && isExtra) {
2314
+ props.dispatch({ t: "extra", kind: props.kind as ExtraKind, name: item.id, on: false })
2315
+ return
2316
+ }
2317
+ props.dispatch({
2318
+ t: "toggle",
2319
+ kind: props.kind as keyof WizardState["excluded"],
2320
+ name: item.id,
2321
+ on,
2322
+ })
2323
+ }}
2324
+ />
2325
+ <div className="as-row-main">
2326
+ <span className="as-row-name" title={descOf.get(item.id) || undefined}>
2327
+ {item.label}
2328
+ {isExtra && <span className="as-tag">added</span>}
2329
+ {conflicts.map((name) => (
2330
+ <span className="as-tag as-tag-warn" key={name}>
2331
+ contradicts {name}
2332
+ </span>
2333
+ ))}
2334
+ </span>
2335
+ {lockedBy && !props.readOnly && <LockNote requirers={lockedBy} id={lockNoteId} />}
2336
+ <DepChips info={props.graph} kind={props.kind} name={item.id} />
2337
+ </div>
2338
+ {isExtra && (
2339
+ <button
2340
+ type="button"
2341
+ className="as-remove"
2342
+ aria-label={`remove ${item.label}`}
2343
+ onClick={() => props.dispatch({ t: "extra", kind: props.kind as ExtraKind, name: item.id, on: false })}
2344
+ >
2345
+ ×
2346
+ </button>
2347
+ )}
2348
+ </div>
2349
+ )
2350
+ })}
2351
+ {props.items.length === 0 && <p className="as-row-desc">none</p>}
2352
+ </div>
2353
+ </CollapsibleSection>
2354
+ )
2355
+ }
2356
+
2357
+ /* ================================================================== */
2358
+ /* post-install result details (shown under the success banner) */
2359
+ /* ================================================================== */
2360
+
2361
+ function ResultDetails(props: { result: InstallResponse }): React.JSX.Element {
2362
+ const r = props.result
2363
+ return (
2364
+ <details className="as-details">
2365
+ <summary className="as-summary">
2366
+ Last install: {r.installed} item(s), {r.files.length} file(s)
2367
+ {r.files.some((f) => f.status === "removed") &&
2368
+ ` (${r.files.filter((f) => f.status === "removed").length} removed)`}
2369
+ </summary>
2370
+ {r.notes.length > 0 && (
2371
+ <ul className="as-notes">
2372
+ {r.notes.map((n, i) => (
2373
+ <li key={i}>{n}</li>
2374
+ ))}
2375
+ </ul>
2376
+ )}
2377
+ <ul className="as-filelist">
2378
+ {r.files.map((f, i) => (
2379
+ <li key={i}>
2380
+ <span
2381
+ className="as-file-status"
2382
+ data-status={f.status}
2383
+ {...(f.status === "removed" ? { "data-removed": "" } : {})}
2384
+ >
2385
+ {f.status}
2386
+ </span>
2387
+ {f.path}
2388
+ </li>
2389
+ ))}
2390
+ </ul>
2391
+ </details>
2392
+ )
2393
+ }
2394
+
2395
+ /* ================================================================== */
2396
+ /* mount */
2397
+ /* ================================================================== */
2398
+
2399
+ const root = document.getElementById("root")
2400
+ if (root) {
2401
+ createRoot(root).render(
2402
+ <StrictMode>
2403
+ <App />
2404
+ </StrictMode>,
2405
+ )
2406
+ }