@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,145 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { isBinaryPath, loadPluginFolder } from './plugin-folder.ts'
4
+
5
+ const MANIFEST = JSON.stringify({
6
+ id: 'demo-plugin',
7
+ name: 'Demo plugin',
8
+ version: '1.0.0',
9
+ apiVersion: 1,
10
+ entry: 'main.js',
11
+ stylesheet: 'main.css',
12
+ capabilities: ['ui.commands'],
13
+ })
14
+
15
+ test('a picked folder drops its own name and keeps the file layout', () => {
16
+ const folder = loadPluginFolder([
17
+ { path: 'demo/main.js', content: 'maxcode.log("hi")' },
18
+ { path: 'demo/styles/main.css', content: '.a{}' },
19
+ { path: 'demo/assets/icon.bin', content: new Uint8Array([1, 2, 3]) },
20
+ ])
21
+ assert.deepEqual(Object.keys(folder.files).sort(), ['assets/icon.bin', 'main.js', 'styles/main.css'])
22
+ assert.equal(folder.entry, 'main.js')
23
+ assert.equal(folder.stylesheet, 'styles/main.css')
24
+ })
25
+
26
+ test('a loose file pick keeps its flat names and needs no stylesheet', () => {
27
+ const folder = loadPluginFolder([{ path: 'main.js', content: '' }])
28
+ assert.equal(folder.entry, 'main.js')
29
+ assert.equal(folder.stylesheet, 'main.css')
30
+ assert.equal(folder.manifest, undefined)
31
+ })
32
+
33
+ test('the folder manifest.json seeds the form and never becomes a package file', () => {
34
+ const folder = loadPluginFolder([
35
+ { path: 'demo/manifest.json', content: MANIFEST },
36
+ { path: 'demo/main.js', content: '' },
37
+ ])
38
+ assert.equal(folder.manifest?.id, 'demo-plugin')
39
+ assert.deepEqual(folder.manifest?.domains, [])
40
+ assert.equal('manifest.json' in folder.files, false)
41
+ })
42
+
43
+ test('a folder without an entry file is rejected with a readable reason', () => {
44
+ assert.throws(() => loadPluginFolder([{ path: 'demo/main.css', content: '.a{}' }]), /\.js entry/)
45
+ assert.throws(() => loadPluginFolder([{ path: 'demo/manifest.json', content: 'not json' }]), /entry/i)
46
+ })
47
+
48
+ test('a broken manifest.json is reported without costing the load', () => {
49
+ const folder = loadPluginFolder([
50
+ { path: 'demo/main.js', content: 'maxcode.log("hi")' },
51
+ { path: 'demo/manifest.json', content: '{oops' },
52
+ ])
53
+ assert.equal(folder.entry, 'main.js')
54
+ assert.equal(folder.manifest, undefined)
55
+ assert.match(folder.manifestError, /valid JSON/)
56
+ const incomplete = loadPluginFolder([
57
+ { path: 'demo/main.js', content: '' },
58
+ { path: 'demo/manifest.json', content: '{"id":"x"}' },
59
+ ])
60
+ assert.equal(incomplete.manifest, undefined)
61
+ assert.match(incomplete.manifestError, /plugin id/)
62
+ })
63
+
64
+ test('the folder manifest decides the entry, so a folder of many files picks the declared one', () => {
65
+ const folder = loadPluginFolder([
66
+ { path: 'demo/manifest.json', content: JSON.stringify({ ...JSON.parse(MANIFEST), entry: 'index.js', stylesheet: 'theme.css' }) },
67
+ { path: 'demo/index.js', content: 'maxcode.log(1)' },
68
+ { path: 'demo/theme.css', content: '.a{}' },
69
+ ])
70
+ assert.equal(folder.entry, 'index.js')
71
+ assert.equal(folder.stylesheet, 'theme.css')
72
+ })
73
+
74
+ test('a folder with a second script is refused with the reason, not a validator error', () => {
75
+ assert.throws(
76
+ () =>
77
+ loadPluginFolder([
78
+ { path: 'demo/main.js', content: '' },
79
+ { path: 'demo/helper.js', content: '' },
80
+ ]),
81
+ /one script and one stylesheet.*helper\.js/s,
82
+ )
83
+ })
84
+
85
+ test('a nested src/main.js is the entry, and a sibling helper is named as the extra', () => {
86
+ const folder = loadPluginFolder([
87
+ { path: 'demo/src/main.js', content: '' },
88
+ { path: 'demo/src/main.css', content: '' },
89
+ ])
90
+ assert.equal(folder.entry, 'src/main.js')
91
+ assert.equal(folder.stylesheet, 'src/main.css')
92
+
93
+ // The extra named must be the helper — naming main.js would mean the entry was misdetected.
94
+ assert.throws(
95
+ () =>
96
+ loadPluginFolder([
97
+ { path: 'demo/src/helper.js', content: '' },
98
+ { path: 'demo/src/main.js', content: '' },
99
+ ]),
100
+ /one script and one stylesheet.*src\/helper\.js/s,
101
+ )
102
+ })
103
+
104
+ test('the entry pick is stable when a folder has more than one .js', () => {
105
+ const many = (order) => loadPluginFolder(order.map((path) => ({ path, content: '' })))
106
+ const error = (order) => {
107
+ try {
108
+ many(order)
109
+ return ''
110
+ } catch (caught) {
111
+ return caught.message
112
+ }
113
+ }
114
+ assert.equal(error(['demo/zed.js', 'demo/alpha.js']), error(['demo/alpha.js', 'demo/zed.js']))
115
+ })
116
+
117
+ test('a declared entry that is not in the folder falls back to detection', () => {
118
+ const folder = loadPluginFolder([
119
+ { path: 'demo/manifest.json', content: JSON.stringify({ ...JSON.parse(MANIFEST), entry: 'missing.js' }) },
120
+ { path: 'demo/main.js', content: '' },
121
+ ])
122
+ assert.equal(folder.entry, 'main.js')
123
+ })
124
+
125
+ test('binary assets are recognised by extension', () => {
126
+ assert.equal(isBinaryPath('assets/icon.png'), true)
127
+ assert.equal(isBinaryPath('fonts/inter.woff2'), true)
128
+ assert.equal(isBinaryPath('main.js'), false)
129
+ assert.equal(isBinaryPath('styles/main.css'), false)
130
+ })
131
+
132
+ test('a package built from a picked folder with no domains round-trips', async () => {
133
+ const { buildPluginArchive, readPluginArchive } = await import('../../shared/plugin-package.mjs')
134
+ const folder = loadPluginFolder([
135
+ { path: 'demo/manifest.json', content: MANIFEST },
136
+ { path: 'demo/main.js', content: 'maxcode.log("hi")' },
137
+ ])
138
+ assert.ok(folder.manifest)
139
+ const archive = buildPluginArchive({
140
+ manifest: folder.manifest,
141
+ files: { ...folder.files, [folder.stylesheet]: '' },
142
+ })
143
+ assert.deepEqual(readPluginArchive(archive).files['main.js'], 'maxcode.log("hi")')
144
+ assert.deepEqual(readPluginArchive(archive).manifest.domains, [])
145
+ })
@@ -0,0 +1,96 @@
1
+ import type { PluginManifest } from '../../shared/plugin-package.mjs'
2
+ import { validatePluginManifest } from '../../shared/plugin-package.mjs'
3
+
4
+ export interface PickedFile {
5
+ path: string
6
+ content: string | Uint8Array
7
+ }
8
+
9
+ export interface PluginFolder {
10
+ entry: string
11
+ stylesheet: string
12
+ files: Record<string, string | Uint8Array>
13
+ manifest?: PluginManifest
14
+ /** A `manifest.json` we would not use, so the load still succeeds and the form keeps its values. */
15
+ manifestError?: string
16
+ }
17
+
18
+ const BINARY = /\.(?:png|jpe?g|gif|webp|ico|woff2?|ttf|otf|eot|wasm|zip|pdf|mp3|mp4)$/i
19
+
20
+ /** Anything not read as UTF-8 text, so a picked image or font survives the round trip. */
21
+ export function isBinaryPath(path: string): boolean {
22
+ return BINARY.test(path)
23
+ }
24
+
25
+ /** A directory pick prefixes every path with the folder the user chose; loose picks do not. */
26
+ function stripRoot(paths: string[]): string[] {
27
+ const slash = paths[0]?.indexOf('/') ?? -1
28
+ if (slash < 0) return paths
29
+ const root = paths[0].slice(0, slash + 1)
30
+ return paths.every((path) => path.startsWith(root)) ? paths.map((path) => path.slice(root.length)) : paths
31
+ }
32
+
33
+ function parseManifest(raw: string | Uint8Array): PluginManifest {
34
+ if (typeof raw !== 'string') throw new Error('manifest.json must be text.')
35
+ let value: unknown
36
+ try {
37
+ value = JSON.parse(raw)
38
+ } catch {
39
+ throw new Error('manifest.json is not valid JSON.')
40
+ }
41
+ return validatePluginManifest(value)
42
+ }
43
+
44
+ /**
45
+ * Turns a picked folder or file set into the sources a plugin package needs. The
46
+ * folder's own `manifest.json` only seeds the form; the package manifest is
47
+ * always rebuilt from the fields, so what you see is what gets packed.
48
+ */
49
+ export function loadPluginFolder(picked: PickedFile[]): PluginFolder {
50
+ const paths = stripRoot(picked.map((file) => file.path))
51
+ const files: Record<string, string | Uint8Array> = {}
52
+ let raw: string | Uint8Array | undefined
53
+ picked.forEach((file, index) => {
54
+ const path = paths[index]
55
+ if (path === 'manifest.json') raw = file.content
56
+ else files[path] = file.content
57
+ })
58
+
59
+ const names = Object.keys(files)
60
+ const usable = (path: string | undefined, extension: string) =>
61
+ path !== undefined && path.endsWith(extension) && names.includes(path) ? path : undefined
62
+ const pick = (preferred: string, extension: string) => {
63
+ if (names.includes(preferred)) return preferred
64
+ const candidates = names.filter((name) => name.endsWith(extension)).sort()
65
+ // `src/main.js` is the entry just as much as `main.js` is; compare leaf names.
66
+ return candidates.find((name) => name.slice(name.lastIndexOf('/') + 1) === preferred) ?? candidates[0]
67
+ }
68
+
69
+ // A folder manifest is a convenience. A broken one must not cost you the load.
70
+ let manifest: PluginManifest | undefined
71
+ let manifestError: string | undefined
72
+ if (raw !== undefined) {
73
+ try {
74
+ manifest = parseManifest(raw)
75
+ } catch (error) {
76
+ manifestError = error instanceof Error ? error.message : 'manifest.json could not be read.'
77
+ }
78
+ }
79
+
80
+ // What the folder declared beats guessing by extension, and makes the pick stable.
81
+ const entry = usable(manifest?.entry, '.js') ?? pick('main.js', '.js')
82
+ if (!entry) throw new Error('A plugin needs one .js entry file. Pick a folder that has one.')
83
+ const stylesheet = usable(manifest?.stylesheet, '.css') ?? pick('main.css', '.css') ?? 'main.css'
84
+
85
+ // The package holds exactly one script and one stylesheet: no imports, no second module.
86
+ const extra = names.filter(
87
+ (name) => name !== entry && name !== stylesheet && /\.(?:m?js|cjs|jsx|tsx?|css)$/i.test(name),
88
+ )
89
+ if (extra.length > 0) {
90
+ throw new Error(
91
+ `A plugin package holds one script and one stylesheet. Remove or merge: ${extra.join(', ')}.`,
92
+ )
93
+ }
94
+
95
+ return { entry, stylesheet, files, manifest, manifestError }
96
+ }
@@ -0,0 +1,87 @@
1
+ import type { Effort, ModelInfo, ReasoningOption } from '../types'
2
+ import { EFFORT_ORDER } from '../data/providers'
3
+
4
+ export function effortValues(model?: ModelInfo | null): Effort[] {
5
+ const option = model?.reasoningOptions.find(
6
+ (entry): entry is Extract<ReasoningOption, { type: 'effort' }> => entry.type === 'effort',
7
+ )
8
+ return option?.values ?? []
9
+ }
10
+
11
+ export function budgetOption(
12
+ model?: ModelInfo | null,
13
+ ): Extract<ReasoningOption, { type: 'budget_tokens' }> | undefined {
14
+ return model?.reasoningOptions.find(
15
+ (entry): entry is Extract<ReasoningOption, { type: 'budget_tokens' }> =>
16
+ entry.type === 'budget_tokens',
17
+ )
18
+ }
19
+
20
+ export function hasBudget(model?: ModelInfo | null) {
21
+ return Boolean(budgetOption(model))
22
+ }
23
+
24
+ export function hasToggle(model?: ModelInfo | null) {
25
+ return Boolean(model?.reasoningOptions.some((entry) => entry.type === 'toggle'))
26
+ }
27
+
28
+ /** Whether the effort control applies: the live model advertises effort or a budget. */
29
+ export function supportsReasoningControl(model?: ModelInfo | null) {
30
+ return effortValues(model).length > 0 || hasBudget(model)
31
+ }
32
+
33
+ /** Whether the model can think at all, even when the provider controls the switch. */
34
+ export function supportsReasoning(model?: ModelInfo | null) {
35
+ return supportsReasoningControl(model) || hasToggle(model)
36
+ }
37
+
38
+ export function defaultEffort(model?: ModelInfo | null): Effort {
39
+ const values = effortValues(model)
40
+ if (values.length === 0) return 'medium'
41
+ for (const candidate of ['medium', 'high', 'low'] as Effort[]) {
42
+ if (values.includes(candidate)) return candidate
43
+ }
44
+ return values[0]
45
+ }
46
+
47
+ export function normalizeEffort(effort: Effort, model?: ModelInfo | null): Effort {
48
+ const values = effortValues(model)
49
+ if (values.length === 0) return effort
50
+ if (values.includes(effort)) return effort
51
+ return defaultEffort(model)
52
+ }
53
+
54
+ export function clampBudget(value: number, model?: ModelInfo | null) {
55
+ const option = budgetOption(model)
56
+ const min = Math.max(0, option?.min ?? 1024)
57
+ const max = Math.max(min, option?.max ?? 32_768)
58
+ return Math.max(min, Math.min(max, value))
59
+ }
60
+
61
+ const BUDGET_BY_EFFORT: Record<Effort, number> = {
62
+ none: 1024,
63
+ minimal: 1024,
64
+ low: 2048,
65
+ medium: 8192,
66
+ high: 16_384,
67
+ xhigh: 24_576,
68
+ max: 32_768,
69
+ }
70
+
71
+ export function budgetForEffort(effort: Effort, model?: ModelInfo | null) {
72
+ return clampBudget(BUDGET_BY_EFFORT[effort] ?? 8192, model)
73
+ }
74
+
75
+ export function effortOptions(model?: ModelInfo | null): Effort[] {
76
+ const values = effortValues(model)
77
+ return [...values].sort((a, b) => EFFORT_ORDER.indexOf(a) - EFFORT_ORDER.indexOf(b))
78
+ }
79
+
80
+ export function reasoningSummary(model?: ModelInfo | null): string {
81
+ if (!model) return 'Choose a model to see live reasoning controls.'
82
+ const values = effortValues(model)
83
+ if (values.length > 0) return `Effort levels from this model: ${values.join(', ')}.`
84
+ if (hasBudget(model)) return 'This model accepts a thinking token budget, derived from the effort level.'
85
+ if (hasToggle(model)) return 'This model controls reasoning itself; no effort value is sent.'
86
+ return 'This model advertises no reasoning controls, so effort does not apply.'
87
+ }
package/src/lib/sse.ts ADDED
@@ -0,0 +1,75 @@
1
+ export async function* readSSE(response: Response): AsyncGenerator<string> {
2
+ if (!response.body) throw new Error('The provider returned an empty response body.')
3
+ const reader = response.body.getReader()
4
+ const decoder = new TextDecoder()
5
+ let buffer = ''
6
+ try {
7
+ while (true) {
8
+ const { done, value } = await reader.read()
9
+ if (done) break
10
+ buffer += decoder.decode(value, { stream: true })
11
+ buffer = buffer.replace(/\r\n/g, '\n')
12
+ let boundary = buffer.indexOf('\n\n')
13
+ while (boundary !== -1) {
14
+ const chunk = buffer.slice(0, boundary)
15
+ buffer = buffer.slice(boundary + 2)
16
+ for (const line of chunk.split('\n')) {
17
+ if (!line.startsWith('data:')) continue
18
+ const data = line.slice(5).trim()
19
+ if (data) yield data
20
+ }
21
+ boundary = buffer.indexOf('\n\n')
22
+ }
23
+ }
24
+ for (const line of buffer.split('\n')) {
25
+ if (line.startsWith('data:')) {
26
+ const data = line.slice(5).trim()
27
+ if (data) yield data
28
+ }
29
+ }
30
+ } finally {
31
+ reader.releaseLock()
32
+ }
33
+ }
34
+
35
+ export function parseErrorMessage(raw: string, status: number): string {
36
+ try {
37
+ const parsed = JSON.parse(raw) as {
38
+ error?: { message?: string; type?: string } | string
39
+ message?: string
40
+ }
41
+ if (typeof parsed.error === 'string') return `${parsed.error} (${status})`
42
+ if (parsed.error?.message) return `${parsed.error.message} (${status})`
43
+ if (parsed.message) return `${parsed.message} (${status})`
44
+ } catch {
45
+ // Fall through to the raw text, which is still useful.
46
+ }
47
+ const trimmed = raw.trim()
48
+ if (trimmed.startsWith('<')) {
49
+ return `The provider returned an HTML error page (${status}). Check the base URL and key.`
50
+ }
51
+ return trimmed ? `${trimmed.slice(0, 500)} (${status})` : `Request failed with status ${status}.`
52
+ }
53
+
54
+ export async function assertOk(response: Response): Promise<void> {
55
+ if (response.ok) return
56
+ const text = await response.text().catch(() => '')
57
+ throw new Error(parseErrorMessage(text, response.status))
58
+ }
59
+
60
+ export function describeRequestError(error: unknown, providerName: string): string {
61
+ if (error instanceof DOMException && error.name === 'AbortError') return 'Stopped.'
62
+ if (error instanceof Error) {
63
+ if (/daemon is not reachable|lost the connection/i.test(error.message)) {
64
+ return 'The local OpenSol daemon is not reachable. Start it with “npm start”, then try again.'
65
+ }
66
+ if (/failed to fetch|networkerror|load failed/i.test(error.message)) {
67
+ return `The local daemon could not reach ${providerName}. Check the network and the provider base URL, then try again.`
68
+ }
69
+ if (/401|403/.test(error.message) && /key|auth|permission|refus|reject/i.test(error.message)) {
70
+ return `${error.message} Check the API key in Settings → Models, or choose a model this key can access.`
71
+ }
72
+ return error.message
73
+ }
74
+ return 'The request failed for an unknown reason.'
75
+ }
@@ -0,0 +1,72 @@
1
+ import test from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { readFileSync } from 'node:fs'
4
+ import { fileURLToPath } from 'node:url'
5
+ import { THEMES, normalizeTheme, resolveTheme, themeIsDark } from './themes.ts'
6
+
7
+ const css = readFileSync(fileURLToPath(new URL('../index.css', import.meta.url)), 'utf8')
8
+
9
+ const TOKENS = [
10
+ 'canvas',
11
+ 'panel',
12
+ 'raised',
13
+ 'line',
14
+ 'ink',
15
+ 'muted',
16
+ 'faint',
17
+ 'accent',
18
+ 'accent-soft',
19
+ 'on-accent',
20
+ 'success',
21
+ 'warning',
22
+ 'danger',
23
+ 'syn-keyword',
24
+ 'syn-string',
25
+ 'syn-number',
26
+ 'syn-title',
27
+ 'syn-attr',
28
+ ]
29
+
30
+ function block(needle) {
31
+ const start = css.indexOf(needle)
32
+ assert.notEqual(start, -1, `missing CSS block for ${needle}`)
33
+ return css.slice(start, css.indexOf('}', start))
34
+ }
35
+
36
+ test('every named palette defines the full semantic token set', () => {
37
+ for (const theme of THEMES) {
38
+ const body = block(`[data-theme='${theme.id}']`)
39
+ for (const token of TOKENS) {
40
+ assert.match(body, new RegExp(`--${token}:`), `${theme.id} is missing --${token}`)
41
+ }
42
+ }
43
+ const light = block(':root,')
44
+ for (const token of TOKENS) {
45
+ assert.match(light, new RegExp(`--${token}:`), `light is missing --${token}`)
46
+ }
47
+ })
48
+
49
+ test('legacy, named, system and invalid themes resolve', () => {
50
+ assert.equal(normalizeTheme('light'), 'light')
51
+ assert.equal(normalizeTheme('dark'), 'dark')
52
+ assert.equal(normalizeTheme('system'), 'system')
53
+ assert.equal(normalizeTheme('night-owl'), 'night-owl')
54
+ assert.equal(normalizeTheme('claude-code'), 'claude-code')
55
+ assert.equal(normalizeTheme('flame'), 'flame')
56
+ assert.equal(normalizeTheme(undefined), 'system')
57
+ assert.equal(normalizeTheme(7), 'system')
58
+ assert.equal(normalizeTheme('Not A Theme'), 'system')
59
+ // Plugin themes are `plugin-<id>:<theme>` slugs the app has not seen at build time.
60
+ assert.equal(normalizeTheme('plugin-demo:sunrise'), 'plugin-demo:sunrise')
61
+ })
62
+
63
+ test('system follows the OS preference while named themes do not', () => {
64
+ assert.equal(resolveTheme('system', true), 'dark')
65
+ assert.equal(resolveTheme('system', false), 'light')
66
+ assert.equal(resolveTheme('light', true), 'light')
67
+ assert.equal(resolveTheme('flame', false), 'flame')
68
+ assert.equal(themeIsDark('light', true), false)
69
+ assert.equal(themeIsDark('dark', false), true)
70
+ assert.equal(themeIsDark('night-owl', false), true)
71
+ assert.equal(themeIsDark('system', true), true)
72
+ })
@@ -0,0 +1,52 @@
1
+ export type BuiltinThemeId = 'light' | 'dark' | 'system' | 'night-owl' | 'claude-code' | 'flame'
2
+
3
+ /** A built-in theme id, or a Safe plugin's `plugin-<plugin>:<theme>` id. */
4
+ export type ThemeId = BuiltinThemeId | (string & {})
5
+
6
+ export type ResolvedThemeId = Exclude<BuiltinThemeId, 'system'> | (string & {})
7
+
8
+ export interface ThemeMeta {
9
+ id: string
10
+ label: string
11
+ /** Whether the `dark` class (and `dark:` Tailwind variants) applies. */
12
+ dark: boolean
13
+ }
14
+
15
+ export const THEMES: ThemeMeta[] = [
16
+ { id: 'light', label: 'Light', dark: false },
17
+ { id: 'dark', label: 'Dark', dark: true },
18
+ { id: 'night-owl', label: 'Night Owl', dark: true },
19
+ { id: 'claude-code', label: 'Claude Code', dark: true },
20
+ { id: 'flame', label: 'Flame', dark: true },
21
+ ]
22
+
23
+ const SLUG = /^[a-z0-9][a-z0-9:_-]{0,79}$/
24
+ const registry = new Map<string, ThemeMeta>(THEMES.map((theme) => [theme.id, theme]))
25
+
26
+ /** Plugin-contributed themes register here so the app knows how to apply them. */
27
+ export function registerTheme(meta: ThemeMeta): void {
28
+ registry.set(meta.id, meta)
29
+ }
30
+
31
+ export function unregisterTheme(id: string): void {
32
+ if (THEMES.some((theme) => theme.id === id)) return
33
+ registry.delete(id)
34
+ }
35
+
36
+ export function isThemeId(value: unknown): value is string {
37
+ return typeof value === 'string' && SLUG.test(value)
38
+ }
39
+
40
+ export function resolveTheme(theme: ThemeId, prefersDark: boolean): string {
41
+ if (theme === 'system') return prefersDark ? 'dark' : 'light'
42
+ return isThemeId(theme) ? theme : 'light'
43
+ }
44
+
45
+ export function themeIsDark(theme: ThemeId, prefersDark: boolean): boolean {
46
+ return registry.get(resolveTheme(theme, prefersDark))?.dark ?? false
47
+ }
48
+
49
+ export function normalizeTheme(value: unknown): ThemeId {
50
+ if (value === 'system') return 'system'
51
+ return isThemeId(value) ? value : 'system'
52
+ }
@@ -0,0 +1,72 @@
1
+ import assert from 'node:assert/strict'
2
+ import test from 'node:test'
3
+ import { computeUsage, dayKey, formatTokens, shortModelName } from './usage.ts'
4
+
5
+ const now = new Date(2026, 0, 15, 15, 0, 0, 0).getTime()
6
+ const hour = 3_600_000
7
+ const day = 86_400_000
8
+
9
+ function assistant(id, createdAt, model, content = '', usage) {
10
+ return {
11
+ id,
12
+ role: 'assistant',
13
+ content,
14
+ reasoning: '',
15
+ toolCalls: [],
16
+ model,
17
+ usage,
18
+ createdAt,
19
+ round: 0,
20
+ }
21
+ }
22
+
23
+ function session(id, updatedAt, messages) {
24
+ return { id, title: id, createdAt: updatedAt, updatedAt, messages }
25
+ }
26
+
27
+ const sessions = [
28
+ session('recent', now, [
29
+ { id: 'u1', role: 'user', content: 'hello', createdAt: now - hour },
30
+ assistant('a1', now - 2 * hour, 'anthropic/claude-sonnet', 'ok', { input: 100, output: 50 }),
31
+ assistant('a2', now - hour, 'anthropic/claude-sonnet', 'x'.repeat(40)),
32
+ ]),
33
+ session('older', now - 10 * day, [
34
+ assistant('a3', now - 10 * day, 'openai/gpt-5', 'ok', { input: 10, output: 10 }),
35
+ ]),
36
+ ]
37
+
38
+ test('counts all sessions and messages', () => {
39
+ const stats = computeUsage(sessions, 'all', now)
40
+ assert.equal(stats.sessions, 2)
41
+ assert.equal(stats.messages, 4)
42
+ assert.equal(stats.tokens, 180)
43
+ assert.equal(stats.activeDays, 2)
44
+ assert.equal(stats.favoriteModel, 'anthropic/claude-sonnet')
45
+ assert.equal(stats.favoriteShare, 67)
46
+ assert.equal(stats.models.length, 2)
47
+ })
48
+
49
+ test('applies the 7 day window', () => {
50
+ const stats = computeUsage(sessions, '7d', now)
51
+ assert.equal(stats.sessions, 1)
52
+ assert.equal(stats.messages, 3)
53
+ assert.equal(stats.tokens, 160)
54
+ assert.equal(stats.favoriteModel, 'anthropic/claude-sonnet')
55
+ assert.equal(stats.peakHour, '2 PM')
56
+ })
57
+
58
+ test('builds a 26 week heatmap that includes the current day', () => {
59
+ const stats = computeUsage(sessions, 'all', now)
60
+ assert.equal(stats.heatmap.length, 26 * 7)
61
+ assert.equal(stats.busiestDay, 3)
62
+ const today = stats.heatmap.find((entry) => entry.key === dayKey(now))
63
+ assert.equal(today?.count, 3)
64
+ })
65
+
66
+ test('formats token counts and model names', () => {
67
+ assert.equal(formatTokens(999), '999')
68
+ assert.equal(formatTokens(1_500), '1.5k')
69
+ assert.equal(formatTokens(5_400_000), '5.4M')
70
+ assert.equal(shortModelName('anthropic/claude-sonnet'), 'claude-sonnet')
71
+ assert.equal(shortModelName('gpt-5'), 'gpt-5')
72
+ })