@bibobonking/opensol 0.0.0-stage → 3.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) 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/shared/plugin-builder.test.mjs +43 -0
  14. package/shared/plugin-package.d.mts +34 -0
  15. package/shared/plugin-package.mjs +197 -0
  16. package/shared/plugin-package.test.mjs +94 -0
  17. package/src/App.tsx +152 -0
  18. package/src/agent/adapters/anthropic.ts +233 -0
  19. package/src/agent/adapters/gemini.ts +225 -0
  20. package/src/agent/adapters/openai-responses.ts +215 -0
  21. package/src/agent/adapters/openai.ts +200 -0
  22. package/src/agent/adapters/shared.ts +96 -0
  23. package/src/agent/approval.ts +20 -0
  24. package/src/agent/compact.ts +234 -0
  25. package/src/agent/index.ts +36 -0
  26. package/src/agent/model.ts +20 -0
  27. package/src/agent/prompt.ts +107 -0
  28. package/src/agent/queue.test.mjs +111 -0
  29. package/src/agent/queue.ts +84 -0
  30. package/src/agent/subagent.ts +109 -0
  31. package/src/agent/tools.ts +541 -0
  32. package/src/components/ChatView.tsx +403 -0
  33. package/src/components/CommandPalette.tsx +416 -0
  34. package/src/components/Composer.tsx +678 -0
  35. package/src/components/ContextBar.tsx +58 -0
  36. package/src/components/DeployModal.tsx +512 -0
  37. package/src/components/ErrorBoundary.tsx +75 -0
  38. package/src/components/HomeDashboard.tsx +189 -0
  39. package/src/components/Logo.tsx +41 -0
  40. package/src/components/Markdown.tsx +232 -0
  41. package/src/components/MessageItem.tsx +655 -0
  42. package/src/components/ModelPicker.tsx +487 -0
  43. package/src/components/PixleSol.tsx +243 -0
  44. package/src/components/PluginBuilder.tsx +285 -0
  45. package/src/components/PluginManager.tsx +341 -0
  46. package/src/components/PreviewPane.tsx +166 -0
  47. package/src/components/SettingsModal.tsx +2052 -0
  48. package/src/components/Sidebar.tsx +504 -0
  49. package/src/components/Splash.tsx +104 -0
  50. package/src/components/ToolCallCard.tsx +177 -0
  51. package/src/components/TopBar.tsx +182 -0
  52. package/src/components/TrajectoryView.tsx +178 -0
  53. package/src/components/WorkspacePanel.tsx +710 -0
  54. package/src/components/ui.tsx +311 -0
  55. package/src/core/kernel.ts +45 -0
  56. package/src/core/plugins/tools/fs.ts +219 -0
  57. package/src/core/plugins/tools/git.ts +83 -0
  58. package/src/core/plugins/tools/grep.ts +98 -0
  59. package/src/core/plugins/tools/shell.ts +88 -0
  60. package/src/core/plugins/tools/subagent.ts +36 -0
  61. package/src/core/plugins/tools/web.ts +97 -0
  62. package/src/core/services/agent-loop.ts +417 -0
  63. package/src/core/services/approval.ts +66 -0
  64. package/src/core/services/compaction.ts +45 -0
  65. package/src/core/services/llm.ts +32 -0
  66. package/src/core/services/sessions.ts +47 -0
  67. package/src/core/services/subagents.ts +15 -0
  68. package/src/core/services/system-prompt.ts +177 -0
  69. package/src/core/services/tools.ts +135 -0
  70. package/src/core/services/ui.ts +109 -0
  71. package/src/core/types.ts +139 -0
  72. package/src/data/providers.ts +125 -0
  73. package/src/data/skills.ts +68 -0
  74. package/src/index.css +776 -0
  75. package/src/lib/boot.ts +94 -0
  76. package/src/lib/context.ts +75 -0
  77. package/src/lib/conversation.test.mjs +64 -0
  78. package/src/lib/conversation.ts +59 -0
  79. package/src/lib/daemon.ts +475 -0
  80. package/src/lib/hooks.ts +33 -0
  81. package/src/lib/permissions.ts +60 -0
  82. package/src/lib/pixlesol.test.mjs +51 -0
  83. package/src/lib/pixlesol.ts +55 -0
  84. package/src/lib/plugin-folder.test.mjs +145 -0
  85. package/src/lib/plugin-folder.ts +96 -0
  86. package/src/lib/reasoning.ts +87 -0
  87. package/src/lib/sse.ts +75 -0
  88. package/src/lib/themes.test.mjs +72 -0
  89. package/src/lib/themes.ts +52 -0
  90. package/src/lib/usage.test.mjs +72 -0
  91. package/src/lib/usage.ts +155 -0
  92. package/src/lib/utils.ts +73 -0
  93. package/src/main.tsx +16 -0
  94. package/src/plugins/api.test.mjs +77 -0
  95. package/src/plugins/api.ts +113 -0
  96. package/src/plugins/bridge.d.mts +1 -0
  97. package/src/plugins/bridge.mjs +26 -0
  98. package/src/plugins/bridge.test.mjs +17 -0
  99. package/src/plugins/index.ts +52 -0
  100. package/src/plugins/preview.test.mjs +18 -0
  101. package/src/plugins/preview.ts +34 -0
  102. package/src/plugins/runtime.ts +194 -0
  103. package/src/plugins/types.ts +40 -0
  104. package/src/store/chats.ts +278 -0
  105. package/src/store/models.ts +120 -0
  106. package/src/store/settings.ts +445 -0
  107. package/src/store/ui.ts +132 -0
  108. package/src/store/workspace.ts +299 -0
  109. package/src/types.ts +337 -0
  110. package/src/vite-env.d.ts +1 -0
  111. package/tailwind.config.js +83 -0
  112. package/tsconfig.json +22 -0
  113. package/tsconfig.tsbuildinfo +1 -0
  114. package/vite.config.ts +19 -0
@@ -0,0 +1,475 @@
1
+ import type {
2
+ AgentMode,
3
+ AppSettings,
4
+ ChatSession,
5
+ DaemonSnapshot,
6
+ DeployEvent,
7
+ DeployProviderId,
8
+ DeployRequest,
9
+ DeployResult,
10
+ ModelInfo,
11
+ ProviderId,
12
+ ProviderStatus,
13
+ SecretsSummary,
14
+ WorkspaceFile,
15
+ WorkspaceInfo,
16
+ } from '../types'
17
+ import type { PluginManifest, PluginCapability } from '../../shared/plugin-package.mjs'
18
+
19
+ export interface InstalledPlugin {
20
+ id: string
21
+ manifest: PluginManifest
22
+ manifestHash: string
23
+ installedAt: string
24
+ safe: boolean
25
+ enabled: boolean
26
+ grants: { capabilities: PluginCapability[]; domains: string[] }
27
+ }
28
+
29
+ export class DaemonError extends Error {
30
+ status: number
31
+ code: string
32
+
33
+ constructor(message: string, status = 0, code = 'daemon_error') {
34
+ super(message)
35
+ this.name = 'DaemonError'
36
+ this.status = status
37
+ this.code = code
38
+ }
39
+ }
40
+
41
+ let csrf = ''
42
+ let sessionPromise: Promise<void> | null = null
43
+
44
+ export function csrfToken() {
45
+ return csrf
46
+ }
47
+
48
+ async function parseError(response: Response): Promise<DaemonError> {
49
+ const raw = await response.text().catch(() => '')
50
+ if (raw) {
51
+ try {
52
+ const parsed = JSON.parse(raw) as { error?: { message?: string; code?: string; status?: number } }
53
+ if (parsed.error?.message) {
54
+ return new DaemonError(
55
+ parsed.error.message,
56
+ parsed.error.status ?? response.status,
57
+ parsed.error.code ?? 'daemon_error',
58
+ )
59
+ }
60
+ } catch {
61
+ // Fall through to the raw response.
62
+ }
63
+ }
64
+ return new DaemonError(
65
+ raw.trim().slice(0, 400) || `The local daemon returned ${response.status}.`,
66
+ response.status,
67
+ 'daemon_error',
68
+ )
69
+ }
70
+
71
+ export async function ensureSession(force = false): Promise<void> {
72
+ if (sessionPromise && !force) return sessionPromise
73
+ sessionPromise = (async () => {
74
+ let response: Response
75
+ try {
76
+ response = await fetch('/api/session', {
77
+ method: 'POST',
78
+ headers: { 'Content-Type': 'application/json' },
79
+ credentials: 'same-origin',
80
+ })
81
+ } catch {
82
+ throw new DaemonError(
83
+ 'OpenSol’s local daemon is not reachable. Start it with “npm start” in the OpenSol folder, then reload this page.',
84
+ 0,
85
+ 'daemon_unreachable',
86
+ )
87
+ }
88
+ if (!response.ok) throw await parseError(response)
89
+ const data = (await response.json()) as { csrf?: string }
90
+ csrf = typeof data.csrf === 'string' ? data.csrf : ''
91
+ })().catch((error) => {
92
+ sessionPromise = null
93
+ throw error
94
+ })
95
+ return sessionPromise
96
+ }
97
+
98
+ export async function api<T>(path: string, init: RequestInit = {}, retry = true): Promise<T> {
99
+ await ensureSession()
100
+ const method = (init.method ?? 'GET').toUpperCase()
101
+ const headers = new Headers(init.headers)
102
+ if (init.body && !headers.has('Content-Type')) headers.set('Content-Type', 'application/json')
103
+ if (!['GET', 'HEAD'].includes(method)) headers.set('x-maxcode-csrf', csrf)
104
+ let response: Response
105
+ try {
106
+ response = await fetch(path, { ...init, method, headers, credentials: 'same-origin' })
107
+ } catch {
108
+ throw new DaemonError(
109
+ 'Lost the connection to the local daemon. Check that it is still running.',
110
+ 0,
111
+ 'daemon_unreachable',
112
+ )
113
+ }
114
+ if (response.status === 401 && retry) {
115
+ await ensureSession(true)
116
+ return api<T>(path, init, false)
117
+ }
118
+ if (!response.ok) throw await parseError(response)
119
+ if (response.status === 204) return undefined as T
120
+ const type = response.headers.get('content-type') ?? ''
121
+ if (!type.includes('application/json')) return (await response.text()) as T
122
+ return (await response.json()) as T
123
+ }
124
+
125
+ function jsonBody(value: unknown): RequestInit {
126
+ return { method: 'POST', body: JSON.stringify(value) }
127
+ }
128
+
129
+ export function getState(): Promise<DaemonSnapshot> {
130
+ return api<DaemonSnapshot>('/api/state')
131
+ }
132
+
133
+ export function getHealth(): Promise<{ ok: boolean; pid: number; port: number; dist: boolean }> {
134
+ return api('/api/health')
135
+ }
136
+
137
+ export function listInstalledPlugins(): Promise<{ plugins: InstalledPlugin[] }> {
138
+ return api('/api/plugins')
139
+ }
140
+
141
+ function base64ToBytes(value: string): Uint8Array {
142
+ const binary = atob(value)
143
+ const bytes = new Uint8Array(binary.length)
144
+ for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index)
145
+ return bytes
146
+ }
147
+
148
+ export async function getInstalledPluginArchive(id: string): Promise<Uint8Array> {
149
+ const result = await api<{ archive: string }>(`/api/plugins/${encodeURIComponent(id)}/package`)
150
+ return base64ToBytes(result.archive)
151
+ }
152
+
153
+ function bytesToBase64(bytes: Uint8Array): string {
154
+ let binary = ''
155
+ for (let offset = 0; offset < bytes.length; offset += 0x8000) {
156
+ binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000))
157
+ }
158
+ return btoa(binary)
159
+ }
160
+
161
+ export function installPluginArchive(archive: Uint8Array): Promise<{ plugin: InstalledPlugin }> {
162
+ return api('/api/plugins', {
163
+ method: 'POST',
164
+ body: JSON.stringify({ archive: bytesToBase64(archive) }),
165
+ })
166
+ }
167
+
168
+ export function savePluginGrants(
169
+ id: string,
170
+ grants: { capabilities: PluginCapability[]; domains: string[] },
171
+ ): Promise<{ plugin: InstalledPlugin }> {
172
+ return api(`/api/plugins/${encodeURIComponent(id)}/grants`, {
173
+ method: 'PUT',
174
+ body: JSON.stringify(grants),
175
+ })
176
+ }
177
+
178
+ export function setPluginTrust(id: string, safe: boolean): Promise<{ plugin: InstalledPlugin }> {
179
+ return api(`/api/plugins/${encodeURIComponent(id)}/safe`, {
180
+ method: 'PUT',
181
+ body: JSON.stringify({ safe }),
182
+ })
183
+ }
184
+
185
+ export function setPluginActivation(id: string, enabled: boolean): Promise<{ plugin: InstalledPlugin }> {
186
+ return api(`/api/plugins/${encodeURIComponent(id)}/enabled`, {
187
+ method: 'PUT',
188
+ body: JSON.stringify({ enabled }),
189
+ })
190
+ }
191
+
192
+ export function removeInstalledPlugin(id: string): Promise<{ id: string; removed: boolean }> {
193
+ return api(`/api/plugins/${encodeURIComponent(id)}`, { method: 'DELETE' })
194
+ }
195
+
196
+ export function pluginNetworkRequest(id: string, request: {
197
+ url: string
198
+ method?: 'GET' | 'POST'
199
+ headers?: Record<string, string>
200
+ body?: string
201
+ }): Promise<{ status: number; contentType: string; body: string }> {
202
+ return api(`/api/plugins/${encodeURIComponent(id)}/network`, {
203
+ method: 'POST',
204
+ body: JSON.stringify(request),
205
+ })
206
+ }
207
+
208
+ export function patchSettings(patch: Partial<AppSettings>): Promise<{ settings: AppSettings }> {
209
+ return api('/api/settings', { method: 'PATCH', body: JSON.stringify(patch) })
210
+ }
211
+
212
+ export function saveChats(chats: { sessions: ChatSession[]; activeId: string | null }) {
213
+ return api<{ sessions: number; activeId: string | null }>('/api/chats', {
214
+ method: 'PUT',
215
+ body: JSON.stringify(chats),
216
+ })
217
+ }
218
+
219
+ export function migrateLegacy(payload: { settings?: unknown; chats?: unknown }) {
220
+ return api<{
221
+ migratedSettings: boolean
222
+ migratedChats: number
223
+ migratedSecrets: boolean
224
+ state: DaemonSnapshot
225
+ }>('/api/migrate', jsonBody(payload))
226
+ }
227
+
228
+ export function listProviders(): Promise<{ providers: ProviderStatus[] }> {
229
+ return api('/api/providers')
230
+ }
231
+
232
+ export function fetchModels(provider: ProviderId) {
233
+ return api<{
234
+ provider: ProviderId
235
+ models: ModelInfo[]
236
+ source: 'live' | 'catalog'
237
+ fetchedAt: number
238
+ note: string
239
+ catalogError: string
240
+ }>(`/api/models?provider=${encodeURIComponent(provider)}`)
241
+ }
242
+
243
+ export function setProviderKey(provider: ProviderId, apiKey: string) {
244
+ if (!apiKey) return api<{ configured: boolean; hint: string }>(`/api/providers/${provider}/key`, { method: 'DELETE' })
245
+ return api<{ configured: boolean; hint: string }>(`/api/providers/${provider}/key`, {
246
+ method: 'PUT',
247
+ body: JSON.stringify({ apiKey }),
248
+ })
249
+ }
250
+
251
+ export function setSearchKey(apiKey: string) {
252
+ return api<{ configured: boolean; hint: string }>('/api/search/key', {
253
+ method: 'PUT',
254
+ body: JSON.stringify({ apiKey }),
255
+ })
256
+ }
257
+
258
+ export function setDeployCredentials(provider: DeployProviderId, fields: Record<string, string>) {
259
+ return api(`/api/deploy/credentials/${provider}`, {
260
+ method: 'PUT',
261
+ body: JSON.stringify(fields),
262
+ })
263
+ }
264
+
265
+ export function resetScope(scope: 'history' | 'secrets' | 'settings' | 'all'): Promise<DaemonSnapshot> {
266
+ return api('/api/reset', jsonBody({ scope }))
267
+ }
268
+
269
+ export function addWorkspace(path: string): Promise<{ workspace: WorkspaceInfo; entry: { path: string; name: string } }> {
270
+ return api('/api/workspaces', jsonBody({ path }))
271
+ }
272
+
273
+ export function forgetWorkspace(): Promise<WorkspaceInfo> {
274
+ return api('/api/workspaces', { method: 'DELETE' })
275
+ }
276
+
277
+ export function browseFolders(path: string) {
278
+ return api<{ path: string; parent: string; home: string; entries: Array<{ name: string; path: string }> }>(
279
+ `/api/workspaces/browse?path=${encodeURIComponent(path)}`,
280
+ )
281
+ }
282
+
283
+ export function getWorkspaceTree(path = '', depth = 6) {
284
+ return api<{
285
+ root: string
286
+ path: string
287
+ entries: Array<{ name: string; path: string; type: 'file' | 'dir' | 'link'; size: number; mtime: number }>
288
+ truncated: boolean
289
+ workspace: WorkspaceInfo
290
+ }>(`/api/workspace/tree?depth=${depth}&path=${encodeURIComponent(path)}`)
291
+ }
292
+
293
+ export function readWorkspaceFile(path: string): Promise<WorkspaceFile> {
294
+ return api(`/api/workspace/file?path=${encodeURIComponent(path)}`)
295
+ }
296
+
297
+ export function writeWorkspaceFile(path: string, content: string) {
298
+ return api<{ path: string; size: number; mtime: number; created: boolean; hash: string }>(
299
+ '/api/workspace/file',
300
+ { method: 'PUT', body: JSON.stringify({ path, content }) },
301
+ )
302
+ }
303
+
304
+ export function createWorkspaceEntry(path: string, type: 'file' | 'dir') {
305
+ return api<{ path: string; type: 'file' | 'dir' }>('/api/workspace/file', {
306
+ method: 'POST',
307
+ body: JSON.stringify({ action: 'create', path, type }),
308
+ })
309
+ }
310
+
311
+ export function renameWorkspaceEntry(from: string, to: string) {
312
+ return api<{ from: string; to: string }>('/api/workspace/file', {
313
+ method: 'POST',
314
+ body: JSON.stringify({ action: 'rename', from, to }),
315
+ })
316
+ }
317
+
318
+ export function deleteWorkspaceEntry(path: string) {
319
+ return api<{ path: string; type: 'file' | 'dir' }>('/api/workspace/file', {
320
+ method: 'POST',
321
+ body: JSON.stringify({ action: 'delete', path }),
322
+ })
323
+ }
324
+
325
+ export function gitSummary() {
326
+ return api<{
327
+ available: boolean
328
+ root: string
329
+ branch: string
330
+ entries: Array<{ status: string; path: string }>
331
+ error: string
332
+ }>('/api/git')
333
+ }
334
+
335
+ export interface DaemonToolResult {
336
+ name: string
337
+ result: unknown
338
+ }
339
+
340
+ export function executeDaemonTool(
341
+ name: string,
342
+ args: Record<string, unknown>,
343
+ mode: AgentMode,
344
+ signal?: AbortSignal,
345
+ /** Groups file edits of one turn so they can be undone together. */
346
+ checkpoint?: string,
347
+ ) {
348
+ return api<DaemonToolResult>(
349
+ '/api/tools/execute',
350
+ { ...jsonBody({ name, args, mode, checkpoint }), signal },
351
+ )
352
+ }
353
+
354
+ export function getPreviewToken() {
355
+ return api<{ token: string }>('/api/preview/token')
356
+ }
357
+
358
+ export function getProjectInstructions() {
359
+ return api<{ file: string; content: string; truncated: boolean }>('/api/workspace/instructions')
360
+ }
361
+
362
+ export interface CheckpointSummary {
363
+ id: string
364
+ createdAt: number
365
+ files: Array<{ path: string; existed: boolean; skipped: boolean }>
366
+ }
367
+
368
+ export function getCheckpoint(id: string) {
369
+ return api<CheckpointSummary>(`/api/checkpoints/${encodeURIComponent(id)}`)
370
+ }
371
+
372
+ export function restoreCheckpoint(id: string) {
373
+ return api<{ id: string; restored: string[]; removed: string[]; skipped: string[] }>(
374
+ `/api/checkpoints/${encodeURIComponent(id)}/restore`,
375
+ { method: 'POST', body: JSON.stringify({}) },
376
+ )
377
+ }
378
+
379
+ export function runGit(action: string, args: Record<string, unknown> = {}, mode: AgentMode = 'build') {
380
+ return api<Record<string, unknown>>('/api/git', jsonBody({ action, args, mode }))
381
+ }
382
+
383
+ export async function requestLLM(
384
+ provider: ProviderId,
385
+ kind: string,
386
+ model: string,
387
+ body: Record<string, unknown>,
388
+ signal?: AbortSignal,
389
+ retry = true,
390
+ ): Promise<Response> {
391
+ await ensureSession()
392
+ let response: Response
393
+ try {
394
+ response = await fetch('/api/llm', {
395
+ method: 'POST',
396
+ headers: {
397
+ 'Content-Type': 'application/json',
398
+ 'x-maxcode-csrf': csrf,
399
+ },
400
+ body: JSON.stringify({ provider, kind, model, body }),
401
+ signal,
402
+ credentials: 'same-origin',
403
+ })
404
+ } catch (error) {
405
+ if (error instanceof DOMException && error.name === 'AbortError') throw error
406
+ throw new DaemonError('Lost the connection to the local daemon while calling the model.', 0, 'daemon_unreachable')
407
+ }
408
+ // The daemon forgets sessions when it restarts; get a fresh one and retry once.
409
+ if (response.status === 401 && retry) {
410
+ await ensureSession(true)
411
+ return requestLLM(provider, kind, model, body, signal, false)
412
+ }
413
+ if (!response.ok) throw await parseError(response)
414
+ return response
415
+ }
416
+
417
+ export async function* streamDeploy(
418
+ request: DeployRequest,
419
+ signal?: AbortSignal,
420
+ ): AsyncGenerator<DeployEvent> {
421
+ await ensureSession()
422
+ let response: Response
423
+ try {
424
+ response = await fetch('/api/deploy', {
425
+ method: 'POST',
426
+ headers: {
427
+ 'Content-Type': 'application/json',
428
+ 'x-maxcode-csrf': csrf,
429
+ },
430
+ body: JSON.stringify(request),
431
+ signal,
432
+ credentials: 'same-origin',
433
+ })
434
+ } catch (error) {
435
+ if (error instanceof DOMException && error.name === 'AbortError') throw error
436
+ throw new DaemonError('Lost the connection to the local daemon while deploying.', 0, 'daemon_unreachable')
437
+ }
438
+ if (!response.ok) throw await parseError(response)
439
+ if (!response.body) throw new DaemonError('The deploy stream was empty.', 502, 'empty_stream')
440
+ const reader = response.body.getReader()
441
+ const decoder = new TextDecoder()
442
+ let buffer = ''
443
+ try {
444
+ while (true) {
445
+ const { done, value } = await reader.read()
446
+ if (done) break
447
+ buffer += decoder.decode(value, { stream: true })
448
+ let index = buffer.indexOf('\n')
449
+ while (index !== -1) {
450
+ const line = buffer.slice(0, index).trim()
451
+ buffer = buffer.slice(index + 1)
452
+ if (line) {
453
+ try {
454
+ yield JSON.parse(line) as DeployEvent
455
+ } catch {
456
+ yield { type: 'log', stream: 'meta', text: line }
457
+ }
458
+ }
459
+ index = buffer.indexOf('\n')
460
+ }
461
+ }
462
+ const tail = buffer.trim()
463
+ if (tail) {
464
+ try {
465
+ yield JSON.parse(tail) as DeployEvent
466
+ } catch {
467
+ yield { type: 'log', stream: 'meta', text: tail }
468
+ }
469
+ }
470
+ } finally {
471
+ reader.releaseLock()
472
+ }
473
+ }
474
+
475
+ export type { DeployResult, SecretsSummary }
@@ -0,0 +1,33 @@
1
+ import { useEffect, useRef, type RefObject } from 'react'
2
+
3
+ export function useClickOutside<T extends HTMLElement>(
4
+ ref: RefObject<T | null>,
5
+ handler: () => void,
6
+ active = true,
7
+ ignoreRef?: RefObject<HTMLElement | null>,
8
+ ) {
9
+ const saved = useRef(handler)
10
+ saved.current = handler
11
+
12
+ useEffect(() => {
13
+ if (!active) return
14
+ const onPointer = (event: PointerEvent) => {
15
+ const node = ref.current
16
+ if (!node) return
17
+ const target = event.target
18
+ if (!(target instanceof Node)) return
19
+ if (node.contains(target)) return
20
+ if (ignoreRef?.current?.contains(target)) return
21
+ saved.current()
22
+ }
23
+ const onKey = (event: KeyboardEvent) => {
24
+ if (event.key === 'Escape') saved.current()
25
+ }
26
+ document.addEventListener('pointerdown', onPointer, true)
27
+ document.addEventListener('keydown', onKey)
28
+ return () => {
29
+ document.removeEventListener('pointerdown', onPointer, true)
30
+ document.removeEventListener('keydown', onKey)
31
+ }
32
+ }, [active, ref, ignoreRef])
33
+ }
@@ -0,0 +1,60 @@
1
+ import type { AppSettings, ToolName, ToolSetting } from '../types'
2
+
3
+ export type PermissionMode = 'ask' | 'accept-edits' | 'full'
4
+
5
+ export const PERMISSION_MODES: Array<{ value: PermissionMode; label: string; description: string }> = [
6
+ {
7
+ value: 'ask',
8
+ label: 'Ask first',
9
+ description: 'Confirm shell commands, edits and git changes before they run.',
10
+ },
11
+ {
12
+ value: 'accept-edits',
13
+ label: 'Accept edits',
14
+ description: 'Apply file edits automatically; still confirm shell and git.',
15
+ },
16
+ {
17
+ value: 'full',
18
+ label: 'Full access',
19
+ description: 'Run every enabled tool without asking. Use only in trusted folders.',
20
+ },
21
+ ]
22
+
23
+ const WRITE_TOOLS: ToolName[] = ['write_file', 'edit_file', 'rewrite_file']
24
+ const GUARDED_TOOLS: ToolName[] = ['shell', 'git', 'subagent']
25
+
26
+ export function permissionMode(tools: AppSettings['tools']): PermissionMode {
27
+ const writesAuto = WRITE_TOOLS.every((tool) => tools[tool]?.approval === 'auto')
28
+ const guardedAuto = GUARDED_TOOLS.every((tool) => tools[tool]?.approval === 'auto')
29
+ if (writesAuto && guardedAuto) return 'full'
30
+ if (writesAuto) return 'accept-edits'
31
+ return 'ask'
32
+ }
33
+
34
+ export function permissionTools(
35
+ tools: AppSettings['tools'],
36
+ mode: PermissionMode,
37
+ ): Record<ToolName, ToolSetting> {
38
+ const next = {} as Record<ToolName, ToolSetting>
39
+ for (const key of Object.keys(tools) as ToolName[]) {
40
+ const mutating = WRITE_TOOLS.includes(key) || GUARDED_TOOLS.includes(key)
41
+ const approval: ToolSetting['approval'] =
42
+ mode === 'full'
43
+ ? 'auto'
44
+ : mode === 'accept-edits'
45
+ ? WRITE_TOOLS.includes(key)
46
+ ? 'auto'
47
+ : GUARDED_TOOLS.includes(key)
48
+ ? 'ask'
49
+ : tools[key].approval
50
+ : mutating
51
+ ? 'ask'
52
+ : tools[key].approval
53
+ next[key] = { ...tools[key], approval }
54
+ }
55
+ return next
56
+ }
57
+
58
+ export function permissionLabel(mode: PermissionMode): string {
59
+ return PERMISSION_MODES.find((entry) => entry.value === mode)?.label ?? 'Ask first'
60
+ }
@@ -0,0 +1,51 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { pixleMood, PIXLE_LABELS } from './pixlesol.ts'
4
+
5
+ const user = { id: 'u', role: 'user', content: 'hi', createdAt: 0 }
6
+ const assistant = (patch) => ({
7
+ id: 'a',
8
+ role: 'assistant',
9
+ content: '',
10
+ reasoning: '',
11
+ toolCalls: [],
12
+ round: 0,
13
+ createdAt: 0,
14
+ ...patch,
15
+ })
16
+ const tool = (status) => ({
17
+ id: 't',
18
+ role: 'tool',
19
+ call: { id: 'c', name: 'read_file', args: {} },
20
+ status,
21
+ round: 0,
22
+ createdAt: 0,
23
+ })
24
+
25
+ test('no messages means PixleSol waves', () => {
26
+ assert.equal(pixleMood([], false), 'wave')
27
+ })
28
+
29
+ test('reasoning while streaming is thinking', () => {
30
+ assert.equal(pixleMood([user, assistant({ reasoning: 'hmm' })], true), 'thinking')
31
+ })
32
+
33
+ test('streaming with content is typing', () => {
34
+ assert.equal(pixleMood([user, assistant({ reasoning: 'hmm', content: 'so far' })], true), 'typing')
35
+ })
36
+
37
+ test('an awaiting tool needs approval, a failed tool is an error', () => {
38
+ assert.equal(pixleMood([user, tool('awaiting')], true), 'alert')
39
+ assert.equal(pixleMood([user, tool('error')], false), 'error')
40
+ })
41
+
42
+ test('a finished answer is happy, a stopped answer is sleepy', () => {
43
+ assert.equal(pixleMood([user, assistant({ content: 'done' })], false), 'happy')
44
+ assert.equal(pixleMood([user, assistant({ stopped: true })], false), 'sleepy')
45
+ })
46
+
47
+ test('every mood has a label', () => {
48
+ for (const mood of Object.keys(PIXLE_LABELS)) {
49
+ assert.ok(PIXLE_LABELS[mood])
50
+ }
51
+ })
@@ -0,0 +1,55 @@
1
+ import type { Message } from '../types'
2
+
3
+ export type PixleMood =
4
+ | 'wave'
5
+ | 'idle'
6
+ | 'thinking'
7
+ | 'working'
8
+ | 'typing'
9
+ | 'happy'
10
+ | 'alert'
11
+ | 'error'
12
+ | 'sleepy'
13
+ | 'party'
14
+
15
+ export const PIXLE_LABELS: Record<PixleMood, string> = {
16
+ wave: 'Ready when you are',
17
+ idle: 'Standing by',
18
+ thinking: 'Thinking it through…',
19
+ working: 'Running tools…',
20
+ typing: 'Writing the answer…',
21
+ happy: 'Answer ready',
22
+ alert: 'Needs your approval',
23
+ error: 'Hit a snag',
24
+ sleepy: 'Waiting for the next turn',
25
+ party: 'All steps finished',
26
+ }
27
+
28
+ /**
29
+ * Pick PixleSol's face from the tail of the conversation. Presentation only —
30
+ * nothing here mutates a session.
31
+ */
32
+ export function pixleMood(messages: Message[], streaming: boolean): PixleMood {
33
+ if (messages.length === 0) return 'wave'
34
+ const last = messages[messages.length - 1]
35
+
36
+ if (last.role === 'tool') {
37
+ if (last.status === 'awaiting') return 'alert'
38
+ if (last.status === 'error') return 'error'
39
+ if (streaming || last.status === 'running') return 'working'
40
+ return 'party'
41
+ }
42
+
43
+ if (last.role === 'assistant') {
44
+ if (last.error) return 'error'
45
+ if (streaming) {
46
+ if (last.reasoning.trim() && !last.content.trim()) return 'thinking'
47
+ return 'typing'
48
+ }
49
+ if (last.stopped) return 'sleepy'
50
+ if (last.content.trim()) return 'happy'
51
+ return 'idle'
52
+ }
53
+
54
+ return streaming ? 'working' : 'idle'
55
+ }