@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,678 @@
1
+ import {
2
+ useEffect,
3
+ useLayoutEffect,
4
+ useMemo,
5
+ useRef,
6
+ useState,
7
+ type ClipboardEvent,
8
+ type DragEvent,
9
+ type KeyboardEvent,
10
+ } from 'react'
11
+ import {
12
+ Brain,
13
+ Check,
14
+ ChevronDown,
15
+ Cpu,
16
+ FileText,
17
+ FolderOpen,
18
+ Globe2,
19
+ ListChecks,
20
+ Paperclip,
21
+ ShieldCheck,
22
+ Slash,
23
+ Sparkles,
24
+ Square,
25
+ Upload,
26
+ X,
27
+ } from 'lucide-react'
28
+ import type { Attachment, Effort } from '../types'
29
+ import { activeModelId, hasCredentials, useSettings } from '../store/settings'
30
+ import { useActiveModel, useProviderModels } from '../store/models'
31
+ import { useUi } from '../store/ui'
32
+ import { useWorkspace } from '../store/workspace'
33
+ import { effortLabel } from '../data/providers'
34
+ import { effortOptions } from '../lib/reasoning'
35
+ import { PERMISSION_MODES, permissionLabel, permissionMode, permissionTools } from '../lib/permissions'
36
+ import { useClickOutside } from '../lib/hooks'
37
+ import { cn, formatBytes, readFileAsDataUrl, readFileAsText } from '../lib/utils'
38
+ import { ModelPicker } from './ModelPicker'
39
+ import { PixleSol } from './PixleSol'
40
+ import type { PixleMood } from '../lib/pixlesol'
41
+
42
+ const TEXT_EXTENSIONS = /\.(txt|md|markdown|ts|tsx|js|jsx|mjs|cjs|json|css|scss|html|vue|svelte|py|go|rs|rb|java|kt|cs|c|h|cpp|hpp|php|swift|sh|bash|zsh|yml|yaml|toml|ini|sql|csv|xml|gitignore|env)$/i
43
+ const MAX_ATTACHMENTS = 6
44
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024
45
+ const MAX_TEXT_BYTES = 512 * 1024
46
+
47
+ const SLASH_COMMANDS = [
48
+ { name: 'search', usage: '/search <query>', description: 'Search the web and answer with sources', takesArgs: true },
49
+ { name: 'plan', usage: '/plan [task]', description: 'Read-only investigation, then a plan', takesArgs: false },
50
+ { name: 'build', usage: '/build [task]', description: 'Allow edits and commands', takesArgs: false },
51
+ { name: 'new', usage: '/new', description: 'Start a new conversation', takesArgs: false },
52
+ { name: 'workspace', usage: '/workspace', description: 'Open the folder panel', takesArgs: false },
53
+ { name: 'deploy', usage: '/deploy', description: 'Deploy a project folder', takesArgs: false },
54
+ { name: 'settings', usage: '/settings', description: 'Open settings', takesArgs: false },
55
+ { name: 'clear', usage: '/clear', description: 'Clear this conversation', takesArgs: false },
56
+ ]
57
+
58
+ function newId() {
59
+ return typeof crypto !== 'undefined' && 'randomUUID' in crypto
60
+ ? crypto.randomUUID()
61
+ : `${Date.now()}-${Math.random().toString(36).slice(2)}`
62
+ }
63
+
64
+ export function Composer({
65
+ value,
66
+ onChange,
67
+ onSend,
68
+ onStop,
69
+ onCommand,
70
+ streaming,
71
+ hints = false,
72
+ autoFocus = false,
73
+ placeholder = 'How can I help you today?',
74
+ contextPct,
75
+ contextTitle,
76
+ pixleMood,
77
+ }: {
78
+ value: string
79
+ onChange: (value: string) => void
80
+ onSend: (text: string, attachments: Attachment[]) => Promise<boolean> | boolean
81
+ onStop: () => void
82
+ onCommand?: (command: string, args: string) => Promise<boolean> | boolean
83
+ streaming: boolean
84
+ hints?: boolean
85
+ autoFocus?: boolean
86
+ placeholder?: string
87
+ contextPct?: number
88
+ contextTitle?: string
89
+ pixleMood?: PixleMood
90
+ }) {
91
+ const settings = useSettings()
92
+ const update = useSettings((state) => state.update)
93
+ const setMode = useSettings((state) => state.setMode)
94
+ const notify = useUi((state) => state.notify)
95
+ const openSettings = useUi((state) => state.openSettings)
96
+ const focusToken = useUi((state) => state.composerFocus)
97
+ const setWorkspaceOpen = useUi((state) => state.setWorkspaceOpen)
98
+ const workspace = useWorkspace((state) => state.info)
99
+ const [attachments, setAttachments] = useState<Attachment[]>([])
100
+ const [focused, setFocused] = useState(false)
101
+ const [dragging, setDragging] = useState(false)
102
+ const [pickerOpen, setPickerOpen] = useState(false)
103
+ const [permissionOpen, setPermissionOpen] = useState(false)
104
+ const [submitting, setSubmitting] = useState(false)
105
+ const [commandIndex, setCommandIndex] = useState(0)
106
+ const textareaRef = useRef<HTMLTextAreaElement>(null)
107
+ const fileRef = useRef<HTMLInputElement>(null)
108
+ const modelTriggerRef = useRef<HTMLButtonElement>(null)
109
+ const permissionRef = useRef<HTMLDivElement>(null)
110
+ const permissionTriggerRef = useRef<HTMLButtonElement>(null)
111
+
112
+ useClickOutside(permissionRef, () => setPermissionOpen(false), permissionOpen, permissionTriggerRef)
113
+
114
+ const modelId = activeModelId(settings)
115
+ const providerModels = useProviderModels(settings.provider)
116
+ const activeModel = useActiveModel()
117
+ const connected = hasCredentials(settings)
118
+ const efforts = effortOptions(activeModel)
119
+ const activeSkills = settings.skills.filter((skill) => skill.enabled).length
120
+ const permission = permissionMode(settings.tools)
121
+ const modelLabel = !connected
122
+ ? 'No provider'
123
+ : modelId
124
+ ? providerModels.find((model) => model.id === modelId)?.name ?? modelId
125
+ : 'Select model'
126
+
127
+ const commandQuery = useMemo(() => {
128
+ if (!value.startsWith('/') || value.includes('\n')) return null
129
+ const first = value.slice(1).split(/\s/)[0]
130
+ if (value.slice(1).includes(' ')) return null
131
+ return first.toLowerCase()
132
+ }, [value])
133
+
134
+ const commandMatches = useMemo(() => {
135
+ if (commandQuery === null) return []
136
+ return SLASH_COMMANDS.filter((command) => command.name.startsWith(commandQuery))
137
+ }, [commandQuery])
138
+
139
+ useEffect(() => {
140
+ setCommandIndex(0)
141
+ }, [commandQuery])
142
+
143
+ useLayoutEffect(() => {
144
+ const node = textareaRef.current
145
+ if (!node) return
146
+ node.style.height = 'auto'
147
+ node.style.height = `${Math.min(Math.max(node.scrollHeight, 52), 220)}px`
148
+ }, [value])
149
+
150
+ useEffect(() => {
151
+ if (!autoFocus) return
152
+ if (!window.matchMedia('(pointer: fine)').matches) return
153
+ const timer = window.setTimeout(() => textareaRef.current?.focus(), 80)
154
+ return () => window.clearTimeout(timer)
155
+ }, [autoFocus])
156
+
157
+ useEffect(() => {
158
+ if (focusToken > 0) textareaRef.current?.focus()
159
+ }, [focusToken])
160
+
161
+ useEffect(() => {
162
+ if (!connected) setPickerOpen(false)
163
+ }, [connected])
164
+
165
+ useEffect(() => {
166
+ if (activeModel && !efforts.length) update({ effort: 'medium' })
167
+ }, [activeModel, efforts.length, update])
168
+
169
+ const addFiles = async (files: File[]) => {
170
+ if (files.length === 0) return
171
+ let room = MAX_ATTACHMENTS - attachments.length
172
+ if (room <= 0) {
173
+ notify(`You can attach up to ${MAX_ATTACHMENTS} files per message.`, 'error')
174
+ return
175
+ }
176
+ const accepted: Attachment[] = []
177
+ for (const file of files) {
178
+ if (room <= 0) {
179
+ notify(`Only ${MAX_ATTACHMENTS} attachments were added.`, 'info')
180
+ break
181
+ }
182
+ const isImage = /^image\/(png|jpeg|webp|gif)$/i.test(file.type)
183
+ const isText = file.type.startsWith('text/') || TEXT_EXTENSIONS.test(file.name)
184
+ if (isImage) {
185
+ if (file.size > MAX_IMAGE_BYTES) {
186
+ notify(`${file.name} is larger than 5 MB.`, 'error')
187
+ continue
188
+ }
189
+ accepted.push({
190
+ id: newId(),
191
+ name: file.name,
192
+ kind: 'image',
193
+ mime: file.type,
194
+ dataUrl: await readFileAsDataUrl(file),
195
+ size: file.size,
196
+ })
197
+ room -= 1
198
+ } else if (isText) {
199
+ if (file.size > MAX_TEXT_BYTES) {
200
+ notify(`${file.name} is larger than 512 KB.`, 'error')
201
+ continue
202
+ }
203
+ accepted.push({
204
+ id: newId(),
205
+ name: file.name,
206
+ kind: 'text',
207
+ mime: file.type || 'text/plain',
208
+ text: await readFileAsText(file),
209
+ size: file.size,
210
+ })
211
+ room -= 1
212
+ } else {
213
+ notify(`Can't attach ${file.name}. Use an image, text or code file.`, 'error')
214
+ }
215
+ }
216
+ if (accepted.length > 0) setAttachments((current) => [...current, ...accepted])
217
+ }
218
+
219
+ const submit = async () => {
220
+ if (streaming || submitting) return
221
+ const text = value.trim()
222
+ if (!text && attachments.length === 0) return
223
+
224
+ if (text.startsWith('/') && !attachments.length) {
225
+ const [rawName, ...rest] = text.slice(1).split(/\s+/)
226
+ const command = SLASH_COMMANDS.find((entry) => entry.name === rawName.toLowerCase())
227
+ if (command) {
228
+ const args = rest.join(' ').trim()
229
+ if (onCommand) {
230
+ setSubmitting(true)
231
+ try {
232
+ const handled = await onCommand(command.name, args)
233
+ if (handled) {
234
+ onChange('')
235
+ requestAnimationFrame(() => textareaRef.current?.focus())
236
+ }
237
+ } catch (error) {
238
+ notify(error instanceof Error ? error.message : 'That command failed.', 'error')
239
+ } finally {
240
+ setSubmitting(false)
241
+ }
242
+ return
243
+ }
244
+ }
245
+ }
246
+
247
+ setSubmitting(true)
248
+ setPickerOpen(false)
249
+ try {
250
+ const accepted = await onSend(value, attachments)
251
+ if (accepted) {
252
+ onChange('')
253
+ setAttachments([])
254
+ requestAnimationFrame(() => textareaRef.current?.focus())
255
+ }
256
+ } catch (error) {
257
+ notify(error instanceof Error ? error.message : 'The message could not be sent.', 'error')
258
+ } finally {
259
+ setSubmitting(false)
260
+ }
261
+ }
262
+
263
+ const chooseCommand = (name: string, takesArgs: boolean) => {
264
+ if (takesArgs) {
265
+ onChange(`/${name} `)
266
+ textareaRef.current?.focus()
267
+ return
268
+ }
269
+ onChange(`/${name}`)
270
+ window.setTimeout(() => void submitWith(`/${name}`), 0)
271
+ }
272
+
273
+ const submitWith = async (text: string) => {
274
+ const [rawName] = text.slice(1).split(/\s+/)
275
+ const command = SLASH_COMMANDS.find((entry) => entry.name === rawName.toLowerCase())
276
+ if (!command || !onCommand) return
277
+ setSubmitting(true)
278
+ try {
279
+ const handled = await onCommand(command.name, text.slice(1).split(/\s+/).slice(1).join(' '))
280
+ if (handled) onChange('')
281
+ } finally {
282
+ setSubmitting(false)
283
+ }
284
+ }
285
+
286
+ const onKeyDown = (event: KeyboardEvent<HTMLTextAreaElement>) => {
287
+ if (commandMatches.length > 0) {
288
+ if (event.key === 'ArrowDown') {
289
+ event.preventDefault()
290
+ setCommandIndex((index) => (index + 1) % commandMatches.length)
291
+ return
292
+ }
293
+ if (event.key === 'ArrowUp') {
294
+ event.preventDefault()
295
+ setCommandIndex((index) => (index - 1 + commandMatches.length) % commandMatches.length)
296
+ return
297
+ }
298
+ if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
299
+ event.preventDefault()
300
+ const command = commandMatches[commandIndex]
301
+ if (command) chooseCommand(command.name, command.takesArgs)
302
+ return
303
+ }
304
+ if (event.key === 'Escape') {
305
+ event.preventDefault()
306
+ onChange('')
307
+ return
308
+ }
309
+ }
310
+ if (event.key === 'Enter' && !event.shiftKey && !event.nativeEvent.isComposing) {
311
+ event.preventDefault()
312
+ if (streaming) return
313
+ void submit()
314
+ }
315
+ if (event.key === 'Escape') setPickerOpen(false)
316
+ }
317
+
318
+ const onPaste = (event: ClipboardEvent<HTMLTextAreaElement>) => {
319
+ const files = Array.from(event.clipboardData.files)
320
+ if (files.length > 0) {
321
+ event.preventDefault()
322
+ void addFiles(files)
323
+ }
324
+ }
325
+
326
+ const onDrop = (event: DragEvent<HTMLDivElement>) => {
327
+ event.preventDefault()
328
+ setDragging(false)
329
+ void addFiles(Array.from(event.dataTransfer.files))
330
+ }
331
+
332
+ const disabledSend = (!value.trim() && attachments.length === 0) || submitting || streaming
333
+ const contextTone =
334
+ contextPct === undefined
335
+ ? ''
336
+ : contextPct >= 95
337
+ ? 'text-danger'
338
+ : contextPct >= 70
339
+ ? 'text-warning'
340
+ : 'text-faint'
341
+
342
+ return (
343
+ <div className="relative w-full">
344
+ <ModelPicker open={pickerOpen && connected} onClose={() => setPickerOpen(false)} triggerRef={modelTriggerRef} />
345
+
346
+ {commandMatches.length > 0 && (
347
+ <div className="absolute bottom-[calc(100%+8px)] left-0 z-40 w-[min(94vw,360px)] overflow-hidden rounded-xl border border-line surface-lift shadow-float">
348
+ <div className="flex items-center gap-1.5 border-b border-line px-3 py-2 text-[10px] font-medium uppercase tracking-[0.12em] text-faint">
349
+ <Slash size={10} /> Commands
350
+ </div>
351
+ {commandMatches.map((command, index) => (
352
+ <button
353
+ key={command.name}
354
+ type="button"
355
+ onMouseDown={(event) => event.preventDefault()}
356
+ onClick={() => chooseCommand(command.name, command.takesArgs)}
357
+ className={cn(
358
+ 'flex w-full items-center gap-2 px-3 py-2 text-left',
359
+ index === commandIndex ? 'bg-accent-soft' : 'hover:bg-raised',
360
+ )}
361
+ >
362
+ <span className={cn('font-mono text-[10.5px]', index === commandIndex ? 'text-accent' : 'text-ink')}>
363
+ {command.usage}
364
+ </span>
365
+ <span className="min-w-0 flex-1 truncate text-[10px] text-faint">{command.description}</span>
366
+ </button>
367
+ ))}
368
+ </div>
369
+ )}
370
+
371
+ <div className="mb-2 flex flex-wrap items-center gap-1.5">
372
+ <button
373
+ type="button"
374
+ onClick={() => setWorkspaceOpen(true)}
375
+ className="chip h-7 max-w-[220px] gap-1.5 px-2.5 text-[11px]"
376
+ title={workspace.available ? workspace.root : 'Choose a project folder'}
377
+ >
378
+ <FolderOpen size={12} className="shrink-0 text-faint" />
379
+ <span className="truncate">{workspace.available ? workspace.name : 'No folder'}</span>
380
+ </button>
381
+ <button
382
+ type="button"
383
+ onClick={() => setMode(settings.mode === 'plan' ? 'build' : 'plan')}
384
+ className="chip h-7 gap-1.5 px-2.5 text-[11px]"
385
+ title={settings.mode === 'plan' ? 'Switch to Build mode' : 'Switch to Plan mode (read-only)'}
386
+ >
387
+ {settings.mode === 'plan' ? (
388
+ <ListChecks size={12} className="text-faint" />
389
+ ) : (
390
+ <Sparkles size={12} className="text-faint" />
391
+ )}
392
+ {settings.mode === 'plan' ? 'Plan mode' : 'Build mode'}
393
+ </button>
394
+ <button
395
+ type="button"
396
+ onClick={() => update({ webSearchEnabled: !settings.webSearchEnabled })}
397
+ className={cn('chip h-7 gap-1.5 px-2.5 text-[11px]', settings.webSearchEnabled && 'chip-active')}
398
+ title="Allow OpenSol to search the web and read pages"
399
+ aria-pressed={settings.webSearchEnabled}
400
+ >
401
+ <Globe2 size={12} />
402
+ Search
403
+ </button>
404
+ <label
405
+ className={cn('chip h-7 cursor-pointer gap-1 px-2', !efforts.length && 'cursor-not-allowed opacity-45')}
406
+ title={efforts.length ? 'Reasoning effort sent to the model' : 'This model advertises no effort levels'}
407
+ >
408
+ <Brain size={12} />
409
+ <select
410
+ value={settings.effort}
411
+ disabled={!efforts.length}
412
+ onChange={(event) => update({ effort: event.target.value as Effort })}
413
+ className="bg-transparent text-[11px] font-medium text-ink outline-none disabled:cursor-not-allowed"
414
+ aria-label="Reasoning effort"
415
+ >
416
+ {(efforts.length ? efforts : [settings.effort]).map((effort) => (
417
+ <option key={effort} value={effort}>
418
+ {effortLabel(effort)}
419
+ </option>
420
+ ))}
421
+ </select>
422
+ </label>
423
+ {activeSkills > 0 && (
424
+ <button
425
+ type="button"
426
+ onClick={() => openSettings('skills')}
427
+ className="chip h-7 gap-1 px-2.5 text-[11px]"
428
+ title="Manage loaded skills"
429
+ >
430
+ {activeSkills} skill{activeSkills === 1 ? '' : 's'}
431
+ </button>
432
+ )}
433
+ </div>
434
+
435
+ <div
436
+ onDragOver={(event) => {
437
+ if (!event.dataTransfer.types.includes('Files')) return
438
+ event.preventDefault()
439
+ setDragging(true)
440
+ }}
441
+ onDragLeave={(event) => {
442
+ if (!event.currentTarget.contains(event.relatedTarget as Node)) setDragging(false)
443
+ }}
444
+ onDrop={onDrop}
445
+ className={cn(
446
+ 'surface-lift composer-shadow relative rounded-2xl border transition-all duration-200',
447
+ focused || dragging ? 'border-accent/35' : 'border-line',
448
+ streaming && 'composer-glow border-accent/30',
449
+ dragging && 'ring-4 ring-accent/10',
450
+ )}
451
+ >
452
+ {focused && !dragging && (
453
+ <div className="pointer-events-none absolute inset-0 rounded-2xl ring-4 ring-accent/[0.03]" aria-hidden="true" />
454
+ )}
455
+
456
+ <div className="relative px-3.5 pb-1 pt-3">
457
+ {attachments.length > 0 && (
458
+ <div className="flex flex-wrap gap-2 px-1 pb-2">
459
+ {attachments.map((attachment) =>
460
+ attachment.kind === 'image' ? (
461
+ <div key={attachment.id} className="relative">
462
+ <img
463
+ src={attachment.dataUrl}
464
+ alt={attachment.name}
465
+ className="h-14 w-14 rounded-lg border border-line object-cover"
466
+ />
467
+ <button
468
+ type="button"
469
+ onClick={() => setAttachments((current) => current.filter((item) => item.id !== attachment.id))}
470
+ className="absolute -right-1.5 -top-1.5 flex h-[18px] w-[18px] items-center justify-center rounded-full border border-line bg-panel text-muted shadow-sm transition-colors hover:text-ink"
471
+ aria-label={`Remove ${attachment.name}`}
472
+ >
473
+ <X size={10} />
474
+ </button>
475
+ </div>
476
+ ) : (
477
+ <span
478
+ key={attachment.id}
479
+ className="inline-flex h-8 max-w-[220px] items-center gap-1.5 rounded-lg border border-line bg-raised/70 pl-2 pr-1 text-[10.5px] text-muted"
480
+ >
481
+ <FileText size={11} className="shrink-0" />
482
+ <span className="min-w-0 flex-1 truncate">{attachment.name}</span>
483
+ <span className="shrink-0 text-faint">{formatBytes(attachment.size)}</span>
484
+ <button
485
+ type="button"
486
+ onClick={() => setAttachments((current) => current.filter((item) => item.id !== attachment.id))}
487
+ className="flex h-5 w-5 shrink-0 items-center justify-center rounded text-faint hover:bg-panel hover:text-ink"
488
+ aria-label={`Remove ${attachment.name}`}
489
+ >
490
+ <X size={10} />
491
+ </button>
492
+ </span>
493
+ ),
494
+ )}
495
+ </div>
496
+ )}
497
+
498
+ <textarea
499
+ ref={textareaRef}
500
+ value={value}
501
+ onChange={(event) => onChange(event.target.value)}
502
+ onKeyDown={onKeyDown}
503
+ onPaste={onPaste}
504
+ onFocus={() => setFocused(true)}
505
+ onBlur={() => setFocused(false)}
506
+ placeholder={placeholder}
507
+ rows={1}
508
+ aria-label="Message OpenSol"
509
+ className="block w-full resize-none bg-transparent px-2 py-1.5 text-[15.5px] leading-[1.6] text-ink outline-none placeholder:text-faint"
510
+ style={{ minHeight: 52, maxHeight: 220 }}
511
+ />
512
+ </div>
513
+
514
+ <div className="flex items-end justify-between gap-2 px-2.5 pb-2 pt-1">
515
+ <div className="flex min-w-0 items-center gap-0.5">
516
+ <div className="relative">
517
+ <button
518
+ ref={permissionTriggerRef}
519
+ type="button"
520
+ onClick={() => setPermissionOpen((open) => !open)}
521
+ className="flex h-8 items-center gap-1.5 rounded-lg px-2 text-[11px] font-medium text-muted transition-colors hover:bg-raised hover:text-ink"
522
+ title="Choose how much OpenSol can do without asking"
523
+ aria-expanded={permissionOpen}
524
+ >
525
+ <ShieldCheck size={13} className="text-faint" />
526
+ {permissionLabel(permission)}
527
+ <ChevronDown size={11} className={cn('text-faint transition-transform', permissionOpen && 'rotate-180')} />
528
+ </button>
529
+ {permissionOpen && (
530
+ <div
531
+ ref={permissionRef}
532
+ className="surface-lift absolute bottom-[calc(100%+8px)] left-0 z-40 w-[290px] overflow-hidden rounded-2xl border border-line p-1.5 shadow-float"
533
+ >
534
+ <p className="px-2 pb-1 pt-1.5 text-[10px] font-medium uppercase tracking-[0.1em] text-faint">
535
+ Permissions
536
+ </p>
537
+ {PERMISSION_MODES.map((entry) => (
538
+ <button
539
+ key={entry.value}
540
+ type="button"
541
+ onClick={() => {
542
+ update({ tools: permissionTools(settings.tools, entry.value) })
543
+ setPermissionOpen(false)
544
+ }}
545
+ className="flex w-full items-start gap-2 rounded-lg px-2 py-1.5 text-left transition-colors hover:bg-raised"
546
+ >
547
+ <span className="mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center">
548
+ {permission === entry.value ? (
549
+ <Check size={12} className="text-accent" />
550
+ ) : (
551
+ <span className="h-1.5 w-1.5 rounded-full bg-line" />
552
+ )}
553
+ </span>
554
+ <span className="min-w-0">
555
+ <span className="block text-[11.5px] font-medium text-ink">{entry.label}</span>
556
+ <span className="mt-0.5 block text-[10px] leading-relaxed text-faint">{entry.description}</span>
557
+ </span>
558
+ </button>
559
+ ))}
560
+ </div>
561
+ )}
562
+ </div>
563
+ <button
564
+ type="button"
565
+ className="icon-button h-8 w-8"
566
+ onClick={() => fileRef.current?.click()}
567
+ title="Attach images or text files"
568
+ aria-label="Attach files"
569
+ >
570
+ <Paperclip size={15} />
571
+ </button>
572
+ <input
573
+ ref={fileRef}
574
+ type="file"
575
+ multiple
576
+ accept="image/png,image/jpeg,image/webp,image/gif,.md,.markdown,.txt,.ts,.tsx,.js,.jsx,.json,.css,.html,.py,.go,.rs,.rb,.java,.cs,.sh,.yml,.yaml,.toml,.sql"
577
+ className="hidden"
578
+ onChange={(event) => {
579
+ if (event.target.files) void addFiles(Array.from(event.target.files))
580
+ event.target.value = ''
581
+ }}
582
+ />
583
+ </div>
584
+
585
+ <div className="flex min-w-0 shrink-0 items-center gap-1">
586
+ {contextPct !== undefined && (
587
+ <span
588
+ className={cn('hidden items-center gap-1 px-1 text-[10.5px] tabular-nums sm:flex', contextTone)}
589
+ title={contextTitle}
590
+ >
591
+ <span className="h-1.5 w-1.5 rounded-full bg-current" />
592
+ {contextPct}%
593
+ </span>
594
+ )}
595
+ <button
596
+ ref={modelTriggerRef}
597
+ type="button"
598
+ disabled={!connected}
599
+ onClick={() => setPickerOpen((open) => !open)}
600
+ className="flex h-8 max-w-[190px] items-center gap-1.5 rounded-lg px-2 text-[11px] font-medium text-muted transition-colors hover:bg-raised hover:text-ink disabled:cursor-not-allowed disabled:opacity-45 sm:max-w-[260px]"
601
+ title={connected ? 'Choose model and reasoning effort' : 'Add a provider key in Settings first'}
602
+ >
603
+ <Cpu size={12} className="shrink-0 text-faint" />
604
+ <span className="truncate">{modelLabel}</span>
605
+ <ChevronDown
606
+ size={11}
607
+ className={cn('shrink-0 text-faint transition-transform duration-150', pickerOpen && 'rotate-180')}
608
+ />
609
+ </button>
610
+ {streaming ? (
611
+ <button
612
+ type="button"
613
+ onClick={onStop}
614
+ className="flex h-8 w-8 items-center justify-center rounded-full bg-ink text-panel transition-opacity hover:opacity-85"
615
+ aria-label="Stop generating"
616
+ title="Stop"
617
+ >
618
+ <Square size={11} fill="currentColor" />
619
+ </button>
620
+ ) : (
621
+ <button
622
+ type="button"
623
+ onClick={() => void submit()}
624
+ disabled={disabledSend}
625
+ className={cn(
626
+ 'flex h-8 w-8 items-center justify-center rounded-full transition-all duration-150',
627
+ disabledSend ? 'cursor-not-allowed bg-raised text-faint' : 'bg-accent text-on-accent hover:brightness-110',
628
+ )}
629
+ aria-label="Send message"
630
+ title="Send (Enter)"
631
+ >
632
+ {submitting ? (
633
+ <span className="h-3 w-3 animate-spin rounded-full border border-current border-t-transparent" />
634
+ ) : (
635
+ <ArrowUpIcon />
636
+ )}
637
+ </button>
638
+ )}
639
+ </div>
640
+ </div>
641
+
642
+ {dragging && (
643
+ <div className="absolute inset-0 z-20 flex flex-col items-center justify-center gap-1.5 rounded-2xl border-2 border-dashed border-accent/45 bg-panel/95">
644
+ <Upload size={18} className="text-accent" />
645
+ <p className="text-[12px] font-medium text-ink">Drop files to attach</p>
646
+ <p className="text-[10.5px] text-faint">Images up to 5 MB · text up to 512 KB</p>
647
+ </div>
648
+ )}
649
+
650
+ {pixleMood && (
651
+ <PixleSol
652
+ mood={pixleMood}
653
+ size={44}
654
+ className="pointer-events-none absolute -top-[34px] right-4 z-10 hidden sm:inline-flex"
655
+ />
656
+ )}
657
+ </div>
658
+
659
+ {hints && (
660
+ <div className="mt-2.5 flex items-center justify-between gap-4 px-2 text-[11px] text-faint">
661
+ <span className="hidden sm:inline">Enter to send · Shift + Enter for a new line · / for commands</span>
662
+ <span className="flex items-center gap-1 sm:ml-auto">
663
+ <ShieldCheck size={11} />
664
+ Local daemon · keys encrypted at rest
665
+ </span>
666
+ </div>
667
+ )}
668
+ </div>
669
+ )
670
+ }
671
+
672
+ function ArrowUpIcon() {
673
+ return (
674
+ <svg viewBox="0 0 16 16" width="15" height="15" fill="none" aria-hidden="true">
675
+ <path d="M8 12.5V3.5M4.2 7.2L8 3.4l3.8 3.8" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
676
+ </svg>
677
+ )
678
+ }