@bibobonking/opensol 0.0.0-stage → 3.0.2

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 (127) hide show
  1. package/.github/workflows/build.yml +25 -0
  2. package/README.md +74 -2
  3. package/dist/assets/index-BmN7pBji.css +1 -0
  4. package/dist/assets/index-DgNDjkOz.js +301 -0
  5. package/dist/favicon.svg +15 -0
  6. package/dist/index.html +55 -0
  7. package/dist/preview-host.html +33 -0
  8. package/index.html +54 -0
  9. package/package.json +43 -4
  10. package/postcss.config.js +6 -0
  11. package/public/favicon.svg +15 -0
  12. package/public/preview-host.html +33 -0
  13. package/server/README.md +75 -0
  14. package/server/daemon.mjs +1082 -0
  15. package/server/lib/checkpoints.mjs +124 -0
  16. package/server/lib/checkpoints.test.mjs +110 -0
  17. package/server/lib/deploy.mjs +436 -0
  18. package/server/lib/http.mjs +237 -0
  19. package/server/lib/plugins.mjs +321 -0
  20. package/server/lib/plugins.test.mjs +140 -0
  21. package/server/lib/providers.mjs +594 -0
  22. package/server/lib/store.mjs +491 -0
  23. package/server/lib/tools.mjs +990 -0
  24. package/server/opensol.mjs +202 -0
  25. package/server/package.json +42 -0
  26. package/shared/plugin-builder.test.mjs +43 -0
  27. package/shared/plugin-package.d.mts +34 -0
  28. package/shared/plugin-package.mjs +197 -0
  29. package/shared/plugin-package.test.mjs +94 -0
  30. package/src/App.tsx +152 -0
  31. package/src/agent/adapters/anthropic.ts +233 -0
  32. package/src/agent/adapters/gemini.ts +225 -0
  33. package/src/agent/adapters/openai-responses.ts +215 -0
  34. package/src/agent/adapters/openai.ts +200 -0
  35. package/src/agent/adapters/shared.ts +96 -0
  36. package/src/agent/approval.ts +20 -0
  37. package/src/agent/compact.ts +234 -0
  38. package/src/agent/index.ts +36 -0
  39. package/src/agent/model.ts +20 -0
  40. package/src/agent/prompt.ts +107 -0
  41. package/src/agent/queue.test.mjs +111 -0
  42. package/src/agent/queue.ts +84 -0
  43. package/src/agent/subagent.ts +109 -0
  44. package/src/agent/tools.ts +541 -0
  45. package/src/components/ChatView.tsx +403 -0
  46. package/src/components/CommandPalette.tsx +416 -0
  47. package/src/components/Composer.tsx +678 -0
  48. package/src/components/ContextBar.tsx +58 -0
  49. package/src/components/DeployModal.tsx +512 -0
  50. package/src/components/ErrorBoundary.tsx +75 -0
  51. package/src/components/HomeDashboard.tsx +189 -0
  52. package/src/components/Logo.tsx +41 -0
  53. package/src/components/Markdown.tsx +232 -0
  54. package/src/components/MessageItem.tsx +655 -0
  55. package/src/components/ModelPicker.tsx +487 -0
  56. package/src/components/PixleSol.tsx +243 -0
  57. package/src/components/PluginBuilder.tsx +285 -0
  58. package/src/components/PluginManager.tsx +341 -0
  59. package/src/components/PreviewPane.tsx +166 -0
  60. package/src/components/SettingsModal.tsx +2052 -0
  61. package/src/components/Sidebar.tsx +504 -0
  62. package/src/components/Splash.tsx +104 -0
  63. package/src/components/ToolCallCard.tsx +177 -0
  64. package/src/components/TopBar.tsx +182 -0
  65. package/src/components/TrajectoryView.tsx +178 -0
  66. package/src/components/WorkspacePanel.tsx +710 -0
  67. package/src/components/ui.tsx +311 -0
  68. package/src/core/kernel.ts +45 -0
  69. package/src/core/plugins/tools/fs.ts +219 -0
  70. package/src/core/plugins/tools/git.ts +83 -0
  71. package/src/core/plugins/tools/grep.ts +98 -0
  72. package/src/core/plugins/tools/shell.ts +88 -0
  73. package/src/core/plugins/tools/subagent.ts +36 -0
  74. package/src/core/plugins/tools/web.ts +97 -0
  75. package/src/core/services/agent-loop.ts +417 -0
  76. package/src/core/services/approval.ts +66 -0
  77. package/src/core/services/compaction.ts +45 -0
  78. package/src/core/services/llm.ts +32 -0
  79. package/src/core/services/sessions.ts +47 -0
  80. package/src/core/services/subagents.ts +15 -0
  81. package/src/core/services/system-prompt.ts +177 -0
  82. package/src/core/services/tools.ts +135 -0
  83. package/src/core/services/ui.ts +109 -0
  84. package/src/core/types.ts +139 -0
  85. package/src/data/providers.ts +125 -0
  86. package/src/data/skills.ts +68 -0
  87. package/src/index.css +776 -0
  88. package/src/lib/boot.ts +94 -0
  89. package/src/lib/context.ts +75 -0
  90. package/src/lib/conversation.test.mjs +64 -0
  91. package/src/lib/conversation.ts +59 -0
  92. package/src/lib/daemon.ts +475 -0
  93. package/src/lib/hooks.ts +33 -0
  94. package/src/lib/permissions.ts +60 -0
  95. package/src/lib/pixlesol.test.mjs +51 -0
  96. package/src/lib/pixlesol.ts +55 -0
  97. package/src/lib/plugin-folder.test.mjs +145 -0
  98. package/src/lib/plugin-folder.ts +96 -0
  99. package/src/lib/reasoning.ts +87 -0
  100. package/src/lib/sse.ts +75 -0
  101. package/src/lib/themes.test.mjs +72 -0
  102. package/src/lib/themes.ts +52 -0
  103. package/src/lib/usage.test.mjs +72 -0
  104. package/src/lib/usage.ts +155 -0
  105. package/src/lib/utils.ts +73 -0
  106. package/src/main.tsx +16 -0
  107. package/src/plugins/api.test.mjs +77 -0
  108. package/src/plugins/api.ts +113 -0
  109. package/src/plugins/bridge.d.mts +1 -0
  110. package/src/plugins/bridge.mjs +26 -0
  111. package/src/plugins/bridge.test.mjs +17 -0
  112. package/src/plugins/index.ts +52 -0
  113. package/src/plugins/preview.test.mjs +18 -0
  114. package/src/plugins/preview.ts +34 -0
  115. package/src/plugins/runtime.ts +194 -0
  116. package/src/plugins/types.ts +40 -0
  117. package/src/store/chats.ts +278 -0
  118. package/src/store/models.ts +120 -0
  119. package/src/store/settings.ts +445 -0
  120. package/src/store/ui.ts +132 -0
  121. package/src/store/workspace.ts +299 -0
  122. package/src/types.ts +337 -0
  123. package/src/vite-env.d.ts +1 -0
  124. package/tailwind.config.js +83 -0
  125. package/tsconfig.json +22 -0
  126. package/tsconfig.tsbuildinfo +1 -0
  127. package/vite.config.ts +19 -0
@@ -0,0 +1,243 @@
1
+ import type { ReactNode } from 'react'
2
+ import { PIXLE_LABELS, type PixleMood } from '../lib/pixlesol'
3
+ import { cn } from '../lib/utils'
4
+
5
+ /**
6
+ * PixleSol is OpenSol's pixel sun. Everything is drawn on one 16×16 crisp-edges
7
+ * grid so the sprite stays sharp at any size. Rays are split into two sets
8
+ * (plus / diagonal) that swap in "busy" moods to give a pixel-art twinkle
9
+ * without ever rotating (rotation would blur the pixels).
10
+ */
11
+ const PALETTE = {
12
+ outline: '#6b3410',
13
+ body: '#fbbf24',
14
+ shade: '#f59e0b',
15
+ highlight: '#fef3c7',
16
+ ray: '#f59e0b',
17
+ eye: '#1a0f06',
18
+ mark: '#4a2508',
19
+ blush: '#fb7185',
20
+ sweat: '#38bdf8',
21
+ mint: '#34d399',
22
+ sky: '#60a5fa',
23
+ rose: '#fb7185',
24
+ } as const
25
+
26
+ type Px = readonly [x: number, y: number, w: number, h: number, color: keyof typeof PALETTE]
27
+
28
+ const MOOD_ANIMATION: Record<PixleMood, string> = {
29
+ wave: 'animate-bob',
30
+ idle: 'animate-float-slow',
31
+ thinking: 'animate-float',
32
+ working: 'animate-bob-fast',
33
+ typing: 'animate-bob',
34
+ happy: 'animate-pop',
35
+ alert: 'animate-bounce',
36
+ error: 'animate-shake',
37
+ sleepy: 'animate-float-slow',
38
+ party: 'animate-pop',
39
+ }
40
+
41
+ const TWINKLE = new Set<PixleMood>(['thinking', 'working', 'typing', 'party', 'wave'])
42
+
43
+ // Body mask: a rounded 8×8 disc centred on (8, 8).
44
+ const BODY_ROWS: Array<[y: number, x0: number, x1: number]> = [
45
+ [4, 6, 9],
46
+ [5, 5, 10],
47
+ [6, 4, 11],
48
+ [7, 4, 11],
49
+ [8, 4, 11],
50
+ [9, 4, 11],
51
+ [10, 5, 10],
52
+ [11, 6, 9],
53
+ ]
54
+
55
+ const BODY = new Set<string>()
56
+ for (const [y, x0, x1] of BODY_ROWS) for (let x = x0; x <= x1; x++) BODY.add(`${x},${y}`)
57
+
58
+ const OUTLINE: Array<[number, number]> = []
59
+ for (let y = 2; y <= 13; y++) {
60
+ for (let x = 2; x <= 13; x++) {
61
+ if (BODY.has(`${x},${y}`)) continue
62
+ const touches =
63
+ BODY.has(`${x - 1},${y}`) || BODY.has(`${x + 1},${y}`) || BODY.has(`${x},${y - 1}`) || BODY.has(`${x},${y + 1}`)
64
+ if (touches) OUTLINE.push([x, y])
65
+ }
66
+ }
67
+
68
+ const RAYS_PLUS: Px[] = [
69
+ [7, 0, 2, 2, 'ray'],
70
+ [7, 14, 2, 2, 'ray'],
71
+ [0, 7, 2, 2, 'ray'],
72
+ [14, 7, 2, 2, 'ray'],
73
+ ]
74
+ const RAYS_DIAG: Px[] = [
75
+ [2, 2, 1, 1, 'ray'],
76
+ [13, 2, 1, 1, 'ray'],
77
+ [2, 13, 1, 1, 'ray'],
78
+ [13, 13, 1, 1, 'ray'],
79
+ [3, 3, 1, 1, 'ray'],
80
+ [12, 3, 1, 1, 'ray'],
81
+ [3, 12, 1, 1, 'ray'],
82
+ [12, 12, 1, 1, 'ray'],
83
+ ]
84
+
85
+ /**
86
+ * PixleSol has no mouth. Its eyes are always plain black dots; mood is carried by
87
+ * where the dots look, how big they are, small marks beside the sun (?, !, z,
88
+ * a sweat drop, confetti) and how the rays behave.
89
+ */
90
+ type EyeSize = 'sm' | 'md' | 'lg' | 'flat'
91
+
92
+ function eyes(size: EyeSize, dx = 0, dy = 0): Px[] {
93
+ const [w, h] = size === 'sm' ? [1, 1] : size === 'md' ? [1, 2] : size === 'lg' ? [2, 2] : [2, 1]
94
+ const y = (size === 'sm' ? 8 : size === 'flat' ? 8 : 7) + dy
95
+ const left = size === 'lg' || size === 'flat' ? 5 : 6
96
+ const right = size === 'lg' || size === 'flat' ? 9 : 9
97
+ return [
98
+ [left + dx, y, w, h, 'eye'],
99
+ [right + dx, y, w, h, 'eye'],
100
+ ]
101
+ }
102
+
103
+ const BLUSH: Px[] = [
104
+ [4, 9, 1, 1, 'blush'],
105
+ [11, 9, 1, 1, 'blush'],
106
+ ]
107
+
108
+ interface Look {
109
+ eyes: Px[]
110
+ extras: Px[]
111
+ /** Hide the diagonal rays so marks beside the sun stay readable. */
112
+ clearCorner?: boolean
113
+ /** Extra class on the eye group (blink / scan animation). */
114
+ eyeClass?: string
115
+ }
116
+
117
+ function look(mood: PixleMood): Look {
118
+ switch (mood) {
119
+ case 'wave':
120
+ return { eyes: eyes('md'), extras: BLUSH, eyeClass: 'pxs-blink' }
121
+ case 'happy':
122
+ return {
123
+ eyes: eyes('lg'),
124
+ extras: [...BLUSH, [13, 1, 1, 3, 'ray'], [12, 2, 3, 1, 'ray']],
125
+ clearCorner: true,
126
+ }
127
+ case 'party':
128
+ return {
129
+ eyes: eyes('lg'),
130
+ extras: [
131
+ [1, 3, 1, 1, 'rose'],
132
+ [14, 2, 1, 1, 'mint'],
133
+ [0, 11, 1, 1, 'sky'],
134
+ [15, 12, 1, 1, 'rose'],
135
+ [4, 0, 1, 1, 'mint'],
136
+ [11, 0, 1, 1, 'sky'],
137
+ [2, 14, 1, 1, 'mint'],
138
+ [13, 14, 1, 1, 'rose'],
139
+ ],
140
+ clearCorner: true,
141
+ }
142
+ case 'thinking':
143
+ return {
144
+ eyes: eyes('md', 1, -1),
145
+ extras: [
146
+ [10, 1, 1, 1, 'mark'],
147
+ [12, 1, 1, 1, 'mark'],
148
+ [14, 1, 1, 1, 'mark'],
149
+ ],
150
+ clearCorner: true,
151
+ }
152
+ case 'working':
153
+ return { eyes: eyes('sm', 0, 1), extras: [], eyeClass: 'pxs-scan' }
154
+ case 'typing':
155
+ return { eyes: eyes('md', 0, 1), extras: [], eyeClass: 'pxs-scan' }
156
+ case 'alert':
157
+ return {
158
+ eyes: eyes('lg'),
159
+ extras: [
160
+ [13, 0, 1, 3, 'mark'],
161
+ [13, 4, 1, 1, 'mark'],
162
+ ],
163
+ clearCorner: true,
164
+ }
165
+ case 'error':
166
+ return {
167
+ eyes: eyes('sm', 0, 1),
168
+ extras: [
169
+ [12, 3, 1, 1, 'sweat'],
170
+ [12, 4, 1, 2, 'sweat'],
171
+ ],
172
+ clearCorner: true,
173
+ }
174
+ case 'sleepy':
175
+ return {
176
+ eyes: eyes('flat'),
177
+ extras: [
178
+ [12, 1, 4, 1, 'mark'],
179
+ [14, 2, 1, 1, 'mark'],
180
+ [13, 3, 1, 1, 'mark'],
181
+ [12, 4, 4, 1, 'mark'],
182
+ ],
183
+ clearCorner: true,
184
+ }
185
+ default:
186
+ return { eyes: eyes('md'), extras: [], eyeClass: 'pxs-blink' }
187
+ }
188
+ }
189
+
190
+ export function PixleSol({
191
+ mood = 'idle',
192
+ size = 40,
193
+ className,
194
+ animate = true,
195
+ }: {
196
+ mood?: PixleMood
197
+ size?: number
198
+ className?: string
199
+ animate?: boolean
200
+ }): ReactNode {
201
+ const label = PIXLE_LABELS[mood]
202
+ const twinkle = animate && TWINKLE.has(mood)
203
+ const face = look(mood)
204
+ return (
205
+ <span
206
+ className={cn('inline-flex shrink-0 select-none', animate && MOOD_ANIMATION[mood], className)}
207
+ role="img"
208
+ aria-label={`PixleSol — ${label}`}
209
+ title={`PixleSol — ${label}`}
210
+ >
211
+ <svg viewBox="0 0 16 16" width={size} height={size} shapeRendering="crispEdges" aria-hidden="true">
212
+ <g className={twinkle ? 'pxs-a' : undefined}>
213
+ {RAYS_PLUS.map(([x, y, w, h, c], i) => (
214
+ <rect key={i} x={x} y={y} width={w} height={h} fill={PALETTE[c]} />
215
+ ))}
216
+ </g>
217
+ <g className={twinkle ? 'pxs-b' : undefined} opacity={face.clearCorner ? 0.35 : undefined}>
218
+ {RAYS_DIAG.map(([x, y, w, h, c], i) => (
219
+ <rect key={i} x={x} y={y} width={w} height={h} fill={PALETTE[c]} />
220
+ ))}
221
+ </g>
222
+ {OUTLINE.map(([x, y]) => (
223
+ <rect key={`o${x},${y}`} x={x} y={y} width={1} height={1} fill={PALETTE.outline} />
224
+ ))}
225
+ {BODY_ROWS.map(([y, x0, x1]) => (
226
+ <rect key={`b${y}`} x={x0} y={y} width={x1 - x0 + 1} height={1} fill={PALETTE.body} />
227
+ ))}
228
+ <rect x={5} y={11} width={6} height={1} fill={PALETTE.shade} />
229
+ <rect x={9} y={10} width={2} height={1} fill={PALETTE.shade} />
230
+ <rect x={6} y={4} width={2} height={1} fill={PALETTE.highlight} />
231
+ <rect x={5} y={5} width={1} height={1} fill={PALETTE.highlight} />
232
+ <g className={animate ? face.eyeClass : undefined}>
233
+ {face.eyes.map(([x, y, w, h, color], index) => (
234
+ <rect key={index} x={x} y={y} width={w} height={h} fill={PALETTE[color]} />
235
+ ))}
236
+ </g>
237
+ {face.extras.map(([x, y, w, h, color], index) => (
238
+ <rect key={`x${index}`} x={x} y={y} width={w} height={h} fill={PALETTE[color]} />
239
+ ))}
240
+ </svg>
241
+ </span>
242
+ )
243
+ }
@@ -0,0 +1,285 @@
1
+ import { useRef, useState } from 'react'
2
+ import { FileUp, FolderTree, Hammer } from 'lucide-react'
3
+ import { buildPluginArchive, validatePluginManifest } from '../../shared/plugin-package.mjs'
4
+ import type { PluginCapability, PluginManifest } from '../../shared/plugin-package.mjs'
5
+ import { installPluginArchive, type InstalledPlugin } from '../lib/daemon'
6
+ import { isBinaryPath, loadPluginFolder, type PluginFolder } from '../lib/plugin-folder'
7
+ import { formatBytes } from '../lib/utils'
8
+ import { useUi } from '../store/ui'
9
+ import { Switch } from './ui'
10
+
11
+ const CAPABILITIES: PluginCapability[] = [
12
+ 'workspace.list',
13
+ 'workspace.read',
14
+ 'workspace.write',
15
+ 'workspace.edit',
16
+ 'ui.commands',
17
+ 'ui.panels',
18
+ 'ui.themes',
19
+ 'network.request',
20
+ ]
21
+
22
+ const ACCEPT = '.js,.css,.json,.txt,.html,.svg,.png,.jpg,.jpeg,.gif,.webp,.woff,.woff2'
23
+
24
+ const fieldClass =
25
+ 'h-8 w-full rounded-lg border border-line bg-panel px-2.5 text-[12px] text-ink outline-none transition-colors placeholder:text-faint focus:border-accent/50'
26
+
27
+ async function toPicked(file: File) {
28
+ const path = (file as File & { webkitRelativePath?: string }).webkitRelativePath || file.name
29
+ return { path, content: isBinaryPath(path) ? new Uint8Array(await file.arrayBuffer()) : await file.text() }
30
+ }
31
+
32
+ export function PluginBuilder({ onInstalled }: { onInstalled?: (plugin: InstalledPlugin) => void }) {
33
+ const notify = useUi((state) => state.notify)
34
+ const [id, setId] = useState('my-plugin')
35
+ const [name, setName] = useState('My plugin')
36
+ const [version, setVersion] = useState('1.0.0')
37
+ const [apiVersion, setApiVersion] = useState<1 | 2>(1)
38
+ const [capabilities, setCapabilities] = useState<PluginCapability[]>(['ui.commands'])
39
+ const [domains, setDomains] = useState('')
40
+ const [folder, setFolder] = useState<PluginFolder | null>(null)
41
+ const folderRef = useRef<HTMLInputElement>(null)
42
+ const fileRef = useRef<HTMLInputElement>(null)
43
+
44
+ const load = async (list: FileList | null) => {
45
+ if (!list?.length) return
46
+ try {
47
+ const picked = await Promise.all([...list].map(toPicked))
48
+ const next = loadPluginFolder(picked)
49
+ setFolder(next)
50
+ // A manifest.json in the folder is a starting point, not the last word: the fields below win.
51
+ if (next.manifest) {
52
+ setId(next.manifest.id)
53
+ setName(next.manifest.name)
54
+ setVersion(next.manifest.version)
55
+ setApiVersion(next.manifest.apiVersion === 2 ? 2 : 1)
56
+ setCapabilities(next.manifest.capabilities)
57
+ setDomains(next.manifest.domains.join(', '))
58
+ }
59
+ notify(
60
+ next.manifestError
61
+ ? `Loaded ${Object.keys(next.files).length} file(s). Its manifest.json was ignored: ${next.manifestError}`
62
+ : `Loaded ${Object.keys(next.files).length} file(s) — entry ${next.entry}.`,
63
+ next.manifestError ? 'error' : 'info',
64
+ )
65
+ } catch (error) {
66
+ setFolder(null)
67
+ notify(error instanceof Error ? error.message : 'Those files are not a plugin folder.', 'error')
68
+ }
69
+ }
70
+
71
+ const build = () => {
72
+ try {
73
+ if (!folder) throw new Error('Load a plugin folder or its files first.')
74
+ const manifest: PluginManifest = {
75
+ id: id.trim(),
76
+ name: name.trim(),
77
+ version: version.trim(),
78
+ apiVersion,
79
+ entry: folder.entry,
80
+ stylesheet: folder.stylesheet,
81
+ capabilities: apiVersion === 2 ? [] : capabilities,
82
+ domains: domains
83
+ .split(/[\s,]+/)
84
+ .map((entry) => entry.trim())
85
+ .filter(Boolean),
86
+ }
87
+ validatePluginManifest(manifest)
88
+ const files = { ...folder.files }
89
+ // A plugin with no stylesheet of its own still ships an empty one.
90
+ files[manifest.stylesheet] ??= ''
91
+ return { archive: buildPluginArchive({ manifest, files }) }
92
+ } catch (error) {
93
+ notify(error instanceof Error ? error.message : 'That package could not be built.', 'error')
94
+ return null
95
+ }
96
+ }
97
+
98
+ const download = () => {
99
+ const built = build()
100
+ if (!built) return
101
+ const url = URL.createObjectURL(new Blob([built.archive as BlobPart], { type: 'application/json' }))
102
+ const link = document.createElement('a')
103
+ link.href = url
104
+ link.download = `${id.trim() || 'plugin'}.plugin.json`
105
+ link.click()
106
+ URL.revokeObjectURL(url)
107
+ }
108
+
109
+ const install = async () => {
110
+ const built = build()
111
+ if (!built) return
112
+ try {
113
+ const { plugin } = await installPluginArchive(built.archive)
114
+ onInstalled?.(plugin)
115
+ notify(`${plugin.manifest.name} installed. It starts sandboxed and disabled.`, 'success')
116
+ } catch (error) {
117
+ notify(error instanceof Error ? error.message : 'The daemon rejected this package.', 'error')
118
+ }
119
+ }
120
+
121
+ const loaded = folder ? Object.keys(folder.files) : []
122
+
123
+ return (
124
+ <details className="rounded-xl border border-line bg-raised/20">
125
+ <summary className="flex cursor-pointer items-center gap-2 px-3 py-2.5 text-[11.5px] font-medium text-ink">
126
+ <Hammer size={13} className="text-faint" />
127
+ Build a plugin package from a folder
128
+ </summary>
129
+ <div className="space-y-2.5 border-t border-line px-3 py-3">
130
+ <p className="text-[10.5px] leading-relaxed text-faint">
131
+ Load a plugin folder, or the files themselves. One <code className="font-mono">.js</code> entry and one
132
+ optional <code className="font-mono">.css</code> stylesheet become the package. A{' '}
133
+ <code className="font-mono">manifest.json</code> in the folder fills these fields in; the fields win.
134
+ </p>
135
+
136
+ <div className="flex items-center gap-2 rounded-lg border border-line bg-panel px-2.5 py-2">
137
+ <Switch
138
+ checked={apiVersion === 2}
139
+ onChange={(on: boolean) => setApiVersion(on ? 2 : 1)}
140
+ label="API v2"
141
+ />
142
+ <span className="text-[10.5px] font-medium text-ink">API v2 — free plugin (full app access)</span>
143
+ {apiVersion === 2 && (
144
+ <span className="ml-auto text-[9.5px] text-accent">ctx = Cordis kernel scope</span>
145
+ )}
146
+ </div>
147
+
148
+ <div className="flex flex-wrap items-center gap-2">
149
+ <button
150
+ type="button"
151
+ onClick={() => folderRef.current?.click()}
152
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-3 text-[10.5px] font-medium text-on-accent hover:bg-accent/90"
153
+ >
154
+ <FolderTree size={12} />
155
+ Load folder
156
+ </button>
157
+ <button
158
+ type="button"
159
+ onClick={() => fileRef.current?.click()}
160
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-line bg-panel px-3 text-[10.5px] font-medium text-ink hover:bg-raised"
161
+ >
162
+ <FileUp size={12} />
163
+ Load files
164
+ </button>
165
+ <input
166
+ type="file"
167
+ multiple
168
+ // React's types predate `webkitdirectory`; set it on the node instead.
169
+ ref={(node) => {
170
+ folderRef.current = node
171
+ if (node) node.webkitdirectory = true
172
+ }}
173
+ className="hidden"
174
+ onChange={(event) => {
175
+ void load(event.target.files)
176
+ event.target.value = ''
177
+ }}
178
+ />
179
+ <input
180
+ ref={fileRef}
181
+ type="file"
182
+ multiple
183
+ accept={ACCEPT}
184
+ className="hidden"
185
+ onChange={(event) => {
186
+ void load(event.target.files)
187
+ event.target.value = ''
188
+ }}
189
+ />
190
+ </div>
191
+
192
+ {folder && (
193
+ <div className="rounded-lg border border-line bg-panel/60 px-2.5 py-2">
194
+ <p className="text-[10.5px] text-muted">
195
+ <span className="font-medium text-ink">{loaded.length} file(s)</span> · entry{' '}
196
+ <code className="font-mono">{folder.entry}</code> · stylesheet{' '}
197
+ <code className="font-mono">{folder.stylesheet}</code>
198
+ </p>
199
+ <ul className="mt-1 space-y-0.5">
200
+ {loaded.slice(0, 6).map((path) => (
201
+ <li key={path} className="truncate font-mono text-[9.5px] text-faint">
202
+ {path} · {formatBytes(
203
+ typeof folder.files[path] === 'string'
204
+ ? new TextEncoder().encode(folder.files[path]).length
205
+ : (folder.files[path] as Uint8Array).byteLength,
206
+ )}
207
+ </li>
208
+ ))}
209
+ {loaded.length > 6 && <li className="font-mono text-[9.5px] text-faint">+{loaded.length - 6} more</li>}
210
+ </ul>
211
+ </div>
212
+ )}
213
+
214
+ <div className="grid gap-2 sm:grid-cols-3">
215
+ <label className="block">
216
+ <span className="text-[10px] font-medium text-muted">Id</span>
217
+ <input className={fieldClass} value={id} onChange={(event) => setId(event.target.value)} />
218
+ </label>
219
+ <label className="block">
220
+ <span className="text-[10px] font-medium text-muted">Name</span>
221
+ <input className={fieldClass} value={name} onChange={(event) => setName(event.target.value)} />
222
+ </label>
223
+ <label className="block">
224
+ <span className="text-[10px] font-medium text-muted">Version</span>
225
+ <input className={fieldClass} value={version} onChange={(event) => setVersion(event.target.value)} />
226
+ </label>
227
+ </div>
228
+
229
+ <div>
230
+ <span className="text-[10px] font-medium text-muted">Capabilities</span>
231
+ <div className="mt-1 flex flex-wrap gap-1.5">
232
+ {apiVersion === 2 ? (
233
+ <p className="text-[10px] text-faint">Not needed — API v2 plugin has full access via the Cordis context.</p>
234
+ ) : CAPABILITIES.map((capability) => {
235
+ const on = capabilities.includes(capability)
236
+ return (
237
+ <button
238
+ key={capability}
239
+ type="button"
240
+ onClick={() =>
241
+ setCapabilities((current) =>
242
+ on ? current.filter((entry) => entry !== capability) : [...current, capability],
243
+ )
244
+ }
245
+ className={`rounded-full border px-2 py-1 font-mono text-[9.5px] ${
246
+ on ? 'border-accent/40 bg-accent-soft text-ink' : 'border-line text-faint'
247
+ }`}
248
+ >
249
+ {capability}
250
+ </button>
251
+ )
252
+ })}
253
+ </div>
254
+ </div>
255
+
256
+ <label className="block">
257
+ <span className="text-[10px] font-medium text-muted">Network domains (optional, comma separated)</span>
258
+ <input
259
+ className={fieldClass}
260
+ value={domains}
261
+ placeholder="Leave empty — a plugin that needs no network declares none"
262
+ onChange={(event) => setDomains(event.target.value)}
263
+ />
264
+ </label>
265
+
266
+ <div className="flex flex-wrap items-center gap-2">
267
+ <button
268
+ type="button"
269
+ onClick={download}
270
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg bg-accent px-3 text-[10.5px] font-medium text-on-accent hover:bg-accent/90"
271
+ >
272
+ Download plugin package
273
+ </button>
274
+ <button
275
+ type="button"
276
+ onClick={() => void install()}
277
+ className="inline-flex h-8 items-center gap-1.5 rounded-lg border border-line bg-panel px-3 text-[10.5px] font-medium text-ink hover:bg-raised"
278
+ >
279
+ Build and install
280
+ </button>
281
+ </div>
282
+ </div>
283
+ </details>
284
+ )
285
+ }