@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.
- package/.github/workflows/build.yml +25 -0
- package/README.md +74 -2
- package/dist/assets/index-BmN7pBji.css +1 -0
- package/dist/assets/index-DgNDjkOz.js +301 -0
- package/dist/favicon.svg +15 -0
- package/dist/index.html +55 -0
- package/dist/preview-host.html +33 -0
- package/index.html +54 -0
- package/package.json +43 -4
- package/postcss.config.js +6 -0
- package/public/favicon.svg +15 -0
- package/public/preview-host.html +33 -0
- package/server/README.md +75 -0
- package/server/daemon.mjs +1082 -0
- package/server/lib/checkpoints.mjs +124 -0
- package/server/lib/checkpoints.test.mjs +110 -0
- package/server/lib/deploy.mjs +436 -0
- package/server/lib/http.mjs +237 -0
- package/server/lib/plugins.mjs +321 -0
- package/server/lib/plugins.test.mjs +140 -0
- package/server/lib/providers.mjs +594 -0
- package/server/lib/store.mjs +491 -0
- package/server/lib/tools.mjs +990 -0
- package/server/opensol.mjs +202 -0
- package/server/package.json +42 -0
- package/shared/plugin-builder.test.mjs +43 -0
- package/shared/plugin-package.d.mts +34 -0
- package/shared/plugin-package.mjs +197 -0
- package/shared/plugin-package.test.mjs +94 -0
- package/src/App.tsx +152 -0
- package/src/agent/adapters/anthropic.ts +233 -0
- package/src/agent/adapters/gemini.ts +225 -0
- package/src/agent/adapters/openai-responses.ts +215 -0
- package/src/agent/adapters/openai.ts +200 -0
- package/src/agent/adapters/shared.ts +96 -0
- package/src/agent/approval.ts +20 -0
- package/src/agent/compact.ts +234 -0
- package/src/agent/index.ts +36 -0
- package/src/agent/model.ts +20 -0
- package/src/agent/prompt.ts +107 -0
- package/src/agent/queue.test.mjs +111 -0
- package/src/agent/queue.ts +84 -0
- package/src/agent/subagent.ts +109 -0
- package/src/agent/tools.ts +541 -0
- package/src/components/ChatView.tsx +403 -0
- package/src/components/CommandPalette.tsx +416 -0
- package/src/components/Composer.tsx +678 -0
- package/src/components/ContextBar.tsx +58 -0
- package/src/components/DeployModal.tsx +512 -0
- package/src/components/ErrorBoundary.tsx +75 -0
- package/src/components/HomeDashboard.tsx +189 -0
- package/src/components/Logo.tsx +41 -0
- package/src/components/Markdown.tsx +232 -0
- package/src/components/MessageItem.tsx +655 -0
- package/src/components/ModelPicker.tsx +487 -0
- package/src/components/PixleSol.tsx +243 -0
- package/src/components/PluginBuilder.tsx +285 -0
- package/src/components/PluginManager.tsx +341 -0
- package/src/components/PreviewPane.tsx +166 -0
- package/src/components/SettingsModal.tsx +2052 -0
- package/src/components/Sidebar.tsx +504 -0
- package/src/components/Splash.tsx +104 -0
- package/src/components/ToolCallCard.tsx +177 -0
- package/src/components/TopBar.tsx +182 -0
- package/src/components/TrajectoryView.tsx +178 -0
- package/src/components/WorkspacePanel.tsx +710 -0
- package/src/components/ui.tsx +311 -0
- package/src/core/kernel.ts +45 -0
- package/src/core/plugins/tools/fs.ts +219 -0
- package/src/core/plugins/tools/git.ts +83 -0
- package/src/core/plugins/tools/grep.ts +98 -0
- package/src/core/plugins/tools/shell.ts +88 -0
- package/src/core/plugins/tools/subagent.ts +36 -0
- package/src/core/plugins/tools/web.ts +97 -0
- package/src/core/services/agent-loop.ts +417 -0
- package/src/core/services/approval.ts +66 -0
- package/src/core/services/compaction.ts +45 -0
- package/src/core/services/llm.ts +32 -0
- package/src/core/services/sessions.ts +47 -0
- package/src/core/services/subagents.ts +15 -0
- package/src/core/services/system-prompt.ts +177 -0
- package/src/core/services/tools.ts +135 -0
- package/src/core/services/ui.ts +109 -0
- package/src/core/types.ts +139 -0
- package/src/data/providers.ts +125 -0
- package/src/data/skills.ts +68 -0
- package/src/index.css +776 -0
- package/src/lib/boot.ts +94 -0
- package/src/lib/context.ts +75 -0
- package/src/lib/conversation.test.mjs +64 -0
- package/src/lib/conversation.ts +59 -0
- package/src/lib/daemon.ts +475 -0
- package/src/lib/hooks.ts +33 -0
- package/src/lib/permissions.ts +60 -0
- package/src/lib/pixlesol.test.mjs +51 -0
- package/src/lib/pixlesol.ts +55 -0
- package/src/lib/plugin-folder.test.mjs +145 -0
- package/src/lib/plugin-folder.ts +96 -0
- package/src/lib/reasoning.ts +87 -0
- package/src/lib/sse.ts +75 -0
- package/src/lib/themes.test.mjs +72 -0
- package/src/lib/themes.ts +52 -0
- package/src/lib/usage.test.mjs +72 -0
- package/src/lib/usage.ts +155 -0
- package/src/lib/utils.ts +73 -0
- package/src/main.tsx +16 -0
- package/src/plugins/api.test.mjs +77 -0
- package/src/plugins/api.ts +113 -0
- package/src/plugins/bridge.d.mts +1 -0
- package/src/plugins/bridge.mjs +26 -0
- package/src/plugins/bridge.test.mjs +17 -0
- package/src/plugins/index.ts +52 -0
- package/src/plugins/preview.test.mjs +18 -0
- package/src/plugins/preview.ts +34 -0
- package/src/plugins/runtime.ts +194 -0
- package/src/plugins/types.ts +40 -0
- package/src/store/chats.ts +278 -0
- package/src/store/models.ts +120 -0
- package/src/store/settings.ts +445 -0
- package/src/store/ui.ts +132 -0
- package/src/store/workspace.ts +299 -0
- package/src/types.ts +337 -0
- package/src/vite-env.d.ts +1 -0
- package/tailwind.config.js +83 -0
- package/tsconfig.json +22 -0
- package/tsconfig.tsbuildinfo +1 -0
- 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
|
+
}
|