@bibobonking/opensol 3.1.2 → 3.1.4

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 (44) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/dist/assets/index-B1pQC9Fe.js +288 -0
  3. package/dist/assets/index-qLr4aZVC.css +1 -0
  4. package/dist/favicon.svg +11 -5
  5. package/dist/index.html +2 -2
  6. package/package.json +1 -1
  7. package/public/favicon.svg +11 -5
  8. package/server/daemon.mjs +42 -4
  9. package/server/lib/deploy.mjs +78 -7
  10. package/server/lib/skills.mjs +128 -0
  11. package/server/lib/tools.mjs +40 -0
  12. package/src/agent/adapters/openai-responses.ts +5 -1
  13. package/src/agent/adapters/openai.ts +15 -2
  14. package/src/agent/prompt.ts +4 -7
  15. package/src/agent/tools.ts +1 -0
  16. package/src/components/DeployModal.tsx +77 -15
  17. package/src/components/FileIcon.tsx +108 -0
  18. package/src/components/HomeDashboard.tsx +9 -2
  19. package/src/components/Logo.tsx +11 -5
  20. package/src/components/ModelDeck.tsx +12 -2
  21. package/src/components/SettingsModal.tsx +144 -199
  22. package/src/components/Sidebar.tsx +4 -5
  23. package/src/components/SystemPromptModal.tsx +168 -0
  24. package/src/components/TopBar.tsx +19 -0
  25. package/src/components/WorkspacePanel.tsx +94 -46
  26. package/src/core/kernel.ts +2 -0
  27. package/src/core/plugins/tools/skill.ts +101 -0
  28. package/src/core/services/agent-loop.ts +26 -28
  29. package/src/core/services/system-prompt.ts +15 -13
  30. package/src/core/services/tools.ts +1 -0
  31. package/src/lib/daemon.ts +19 -0
  32. package/src/lib/permissions.ts +1 -1
  33. package/src/lib/retry.test.mjs +17 -0
  34. package/src/lib/retry.ts +13 -0
  35. package/src/lib/skills.test.mjs +22 -0
  36. package/src/lib/skills.ts +93 -0
  37. package/src/lib/usage.ts +12 -0
  38. package/src/store/chats.ts +1 -0
  39. package/src/store/settings.ts +21 -9
  40. package/src/types.ts +14 -1
  41. package/tsconfig.tsbuildinfo +1 -1
  42. package/dist/assets/index-CeN54A2N.css +0 -1
  43. package/dist/assets/index-fhIO9a7_.js +0 -318
  44. package/src/data/skills.ts +0 -68
@@ -5,6 +5,7 @@ import {
5
5
  chmod,
6
6
  lstat,
7
7
  mkdir,
8
+ mkdtemp,
8
9
  readdir,
9
10
  readFile,
10
11
  realpath,
@@ -14,6 +15,7 @@ import {
14
15
  writeFile,
15
16
  } from 'node:fs/promises'
16
17
  import { isIP } from 'node:net'
18
+ import os from 'node:os'
17
19
  import path from 'node:path'
18
20
  import { HttpError } from './http.mjs'
19
21
  import { loadState, loadSecretsSafe } from './store.mjs'
@@ -971,6 +973,39 @@ export async function checkpointSummary(id) {
971
973
  return checkpoint ? describeCheckpoint(checkpoint) : null
972
974
  }
973
975
 
976
+ /**
977
+ * Skills are uploaded as text files. Materialise them in a throwaway folder,
978
+ * run one command there, and delete the folder so nothing leaks between calls.
979
+ */
980
+ async function runSkillScript({ files, command }) {
981
+ if (!files || typeof files !== 'object') throw new HttpError(400, 'The skill has no files.', 'bad_skill')
982
+ const entries = Object.entries(files).filter(
983
+ (entry) => typeof entry[1] === 'string' && typeof entry[0] === 'string' && entry[0].trim(),
984
+ )
985
+ if (entries.length === 0) throw new HttpError(400, 'The skill has no files.', 'bad_skill')
986
+ if (entries.length > 200) throw new HttpError(400, 'A skill may ship at most 200 files.', 'skill_too_large')
987
+ const root = await mkdtemp(path.join(os.tmpdir(), 'opensol-skill-'))
988
+ try {
989
+ for (const [relative, content] of entries) {
990
+ const clean = relative.replace(/\\/g, '/').replace(/^\/+/, '')
991
+ const absolute = path.resolve(root, clean)
992
+ if (!absolute.startsWith(`${root}${path.sep}`)) {
993
+ throw new HttpError(400, `Unsafe path in skill files: ${relative}`, 'bad_skill')
994
+ }
995
+ await mkdir(path.dirname(absolute), { recursive: true })
996
+ await writeFile(absolute, content.slice(0, 512 * 1024))
997
+ }
998
+ const result = await runProcess('/bin/bash', ['-lc', String(command ?? '')], {
999
+ cwd: root,
1000
+ timeoutMs: 120_000,
1001
+ env: { OPENSOL_SKILL_ROOT: root },
1002
+ })
1003
+ return result
1004
+ } finally {
1005
+ await rm(root, { recursive: true, force: true }).catch(() => undefined)
1006
+ }
1007
+ }
1008
+
974
1009
  export async function executeTool(name, args = {}, { mode = 'build', checkpoint = '' } = {}) {
975
1010
  const input = args && typeof args === 'object' ? args : {}
976
1011
  // The prompt promises the daemon enforces plan mode, so enforce it here and not only in the UI.
@@ -981,6 +1016,9 @@ export async function executeTool(name, args = {}, { mode = 'build', checkpoint
981
1016
  'plan_readonly',
982
1017
  )
983
1018
  }
1019
+ if (mode === 'plan' && name === 'skill_run') {
1020
+ throw new HttpError(403, 'Plan mode is read-only. Skill scripts may change the machine.', 'plan_readonly')
1021
+ }
984
1022
  if (checkpoint && FILE_MUTATIONS.has(name)) {
985
1023
  try {
986
1024
  const { root, absolute, relative } = await resolveWorkspacePath(String(input.path ?? ''), { allowRoot: false })
@@ -1017,6 +1055,8 @@ export async function executeTool(name, args = {}, { mode = 'build', checkpoint
1017
1055
  return readUrl(String(input.url ?? ''))
1018
1056
  case 'git':
1019
1057
  return runGit(String(input.action ?? 'status'), input, { mode })
1058
+ case 'skill_run':
1059
+ return runSkillScript({ files: input.files, command: input.command })
1020
1060
  default:
1021
1061
  throw new HttpError(400, `Unknown tool: ${name}`, 'unknown_tool')
1022
1062
  }
@@ -190,7 +190,11 @@ export async function streamOpenAIResponses(request: AdapterRequest): Promise<Ad
190
190
  const done = itemRecord(event.response)
191
191
  const value = itemRecord(done.usage)
192
192
  if (typeof value.input_tokens === 'number' || typeof value.output_tokens === 'number') {
193
- usage = { input: Number(value.input_tokens) || undefined, output: Number(value.output_tokens) || undefined }
193
+ usage = {
194
+ input: Number(value.input_tokens) || undefined,
195
+ output: Number(value.output_tokens) || undefined,
196
+ cost: typeof value.cost === 'number' ? value.cost : undefined,
197
+ }
194
198
  handlers.onUsage(usage)
195
199
  }
196
200
  }
@@ -95,6 +95,10 @@ export async function streamOpenAI(request: AdapterRequest): Promise<AdapterResu
95
95
  if (settings.provider === 'openai' || settings.provider === 'openrouter') {
96
96
  body.stream_options = { include_usage: true }
97
97
  }
98
+ if (settings.provider === 'openrouter') {
99
+ // Ask OpenRouter to include the credit cost in the final usage chunk.
100
+ body.usage = { include: true }
101
+ }
98
102
  if (usesCompletionTokens(settings.provider, modelId)) {
99
103
  body.max_completion_tokens = settings.maxTokens
100
104
  } else {
@@ -134,8 +138,17 @@ export async function streamOpenAI(request: AdapterRequest): Promise<AdapterResu
134
138
  }
135
139
 
136
140
  if (payload.usage && typeof payload.usage === 'object') {
137
- const value = payload.usage as { prompt_tokens?: number; completion_tokens?: number }
138
- usage = { input: value.prompt_tokens, output: value.completion_tokens }
141
+ const value = payload.usage as {
142
+ prompt_tokens?: number
143
+ completion_tokens?: number
144
+ cost?: number
145
+ total_cost?: number
146
+ }
147
+ usage = {
148
+ input: value.prompt_tokens,
149
+ output: value.completion_tokens,
150
+ cost: typeof value.cost === 'number' ? value.cost : typeof value.total_cost === 'number' ? value.total_cost : undefined,
151
+ }
139
152
  handlers.onUsage(usage)
140
153
  }
141
154
 
@@ -36,13 +36,10 @@ export function buildSystemPrompt(settings: AppSettings, mode: AgentMode = setti
36
36
  if (skills.length > 0) {
37
37
  chunks.push(
38
38
  [
39
- '## Loaded skills',
40
- 'Apply these skills when the task matches their purpose. They refine the working rules above; they never override safety, tool permissions or plan mode.',
41
- ...skills.map(
42
- (skill) =>
43
- `<skill name="${skill.name}">\nPurpose: ${skill.description}\n${skill.instructions.trim()}\n</skill>`,
44
- ),
45
- ].join('\n\n'),
39
+ '## Available skills',
40
+ 'Call the `skill` tool to load the full instructions (and run scripts from the skill folder) when a task matches one of these. Do not guess a skill’s contents.',
41
+ ...skills.map((skill) => `- ${skill.name} (${skill.id}): ${skill.description}`),
42
+ ].join('\n'),
46
43
  )
47
44
  }
48
45
 
@@ -519,6 +519,7 @@ export function toolLabel(name: ToolName): string {
519
519
  read_url: 'Read URL',
520
520
  git: 'Git',
521
521
  subagent: 'Subagent',
522
+ skill: 'Skill',
522
523
  }
523
524
  return labels[name as BuiltinToolName] ?? 'Tool'
524
525
  }
@@ -9,12 +9,14 @@ import {
9
9
  Play,
10
10
  Rocket,
11
11
  ShieldCheck,
12
+ Sparkles,
12
13
  Square,
13
14
  Triangle,
14
15
  Terminal,
16
+ X,
15
17
  } from 'lucide-react'
16
18
  import type { DeployProviderId, DeployResult } from '../types'
17
- import { streamDeploy } from '../lib/daemon'
19
+ import { inspectDeploy, streamDeploy, type DeployInspection } from '../lib/daemon'
18
20
  import { cn } from '../lib/utils'
19
21
  import { useSettings } from '../store/settings'
20
22
  import { useUi } from '../store/ui'
@@ -74,9 +76,11 @@ export function DeployModal() {
74
76
 
75
77
  const [provider, setProvider] = useState<DeployProviderId>('cloudflare')
76
78
  const [path, setPath] = useState('')
77
- const [outputDir, setOutputDir] = useState('dist')
79
+ const [outputDir, setOutputDir] = useState('')
78
80
  const [buildCommand, setBuildCommand] = useState('')
79
81
  const [projectName, setProjectName] = useState('')
82
+ const [inspection, setInspection] = useState<DeployInspection | null>(null)
83
+ const [inspecting, setInspecting] = useState(false)
80
84
  const [running, setRunning] = useState(false)
81
85
  const [logs, setLogs] = useState<LogLine[]>([])
82
86
  const [result, setResult] = useState<DeployResult | null>(null)
@@ -89,10 +93,15 @@ export function DeployModal() {
89
93
  const meta = PROVIDERS.find((entry) => entry.id === provider) ?? PROVIDERS[0]
90
94
  const tool = deployTools?.[provider]
91
95
  const configured = Boolean(deploySecrets?.[provider]?.configured)
96
+ const lastUrl = deploySecrets?.[provider]?.fields?.lastUrl ?? ''
92
97
 
93
98
  useEffect(() => {
94
99
  if (!open) return
95
100
  setPath(workspaceRoot)
101
+ setOutputDir('')
102
+ setBuildCommand('')
103
+ setProjectName('')
104
+ setInspection(null)
96
105
  setResult(null)
97
106
  setError('')
98
107
  setLogs([])
@@ -100,6 +109,30 @@ export function DeployModal() {
100
109
  setCopied(false)
101
110
  }, [open, workspaceRoot])
102
111
 
112
+ const detect = async (target = path) => {
113
+ if (!target.trim()) return
114
+ setInspecting(true)
115
+ try {
116
+ const info = await inspectDeploy(target.trim())
117
+ setInspection(info)
118
+ if (info.buildCommand) setBuildCommand((value) => value || info.buildCommand)
119
+ if (info.outputDir) setOutputDir((value) => value || info.outputDir)
120
+ if (info.name) setProjectName((value) => value || info.name)
121
+ } catch (cause) {
122
+ setInspection(null)
123
+ setError(cause instanceof Error ? cause.message : 'Could not inspect that folder.')
124
+ } finally {
125
+ setInspecting(false)
126
+ }
127
+ }
128
+
129
+ // Fill the form from the real project once the path settles.
130
+ useEffect(() => {
131
+ if (!open || !path.trim()) return
132
+ const timer = window.setTimeout(() => void detect(path), 450)
133
+ return () => window.clearTimeout(timer)
134
+ }, [open, path])
135
+
103
136
  useEffect(() => {
104
137
  if (!open) return
105
138
  const node = logRef.current
@@ -201,7 +234,7 @@ export function DeployModal() {
201
234
  </div>
202
235
  </div>
203
236
  <button type="button" className="icon-button -mr-1 shrink-0" onClick={close} aria-label="Close deploy panel">
204
- <span className="text-[18px] leading-none">×</span>
237
+ <X size={16} />
205
238
  </button>
206
239
  </div>
207
240
 
@@ -221,16 +254,16 @@ export function DeployModal() {
221
254
  onClick={() => setProvider(entry.id)}
222
255
  disabled={running}
223
256
  className={cn(
224
- 'flex items-start gap-2.5 rounded-xl border px-3 py-2.5 text-left transition-colors disabled:opacity-60',
257
+ 'group/host flex items-start gap-2.5 rounded-xl border px-3 py-2.5 text-left transition-all duration-200 disabled:opacity-60',
225
258
  active
226
- ? 'border-accent/35 bg-accent-soft/45'
227
- : 'border-line bg-panel hover:bg-raised/50',
259
+ ? 'border-accent/40 bg-gradient-to-br from-accent-soft/80 to-panel shadow-pill'
260
+ : 'border-line bg-panel hover:border-line hover:bg-raised/50',
228
261
  )}
229
262
  >
230
263
  <span
231
264
  className={cn(
232
- 'mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg',
233
- active ? 'bg-accent text-on-accent' : 'bg-raised text-muted',
265
+ 'mt-0.5 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg transition-colors',
266
+ active ? 'bg-accent text-on-accent shadow-pill' : 'bg-raised text-muted group-hover/host:text-ink',
234
267
  )}
235
268
  >
236
269
  <Icon size={13} />
@@ -254,6 +287,18 @@ export function DeployModal() {
254
287
  </div>
255
288
  </div>
256
289
 
290
+ {lastUrl && (
291
+ <a
292
+ href={lastUrl}
293
+ target="_blank"
294
+ rel="noreferrer"
295
+ className="flex items-center gap-1.5 rounded-lg border border-line bg-raised/30 px-2.5 py-1.5 text-[9.5px] text-muted hover:text-ink"
296
+ >
297
+ <ExternalLink size={10} className="shrink-0 text-faint" />
298
+ <span className="truncate">Last deploy: {lastUrl}</span>
299
+ </a>
300
+ )}
301
+
257
302
  {!configured && (
258
303
  <div className="rounded-xl border border-warning/25 bg-warning/[0.045] p-3">
259
304
  <p className="flex items-start gap-1.5 text-[10.5px] leading-relaxed text-muted">
@@ -300,10 +345,27 @@ export function DeployModal() {
300
345
  spellCheck={false}
301
346
  className="h-9 w-full rounded-lg border border-line bg-panel px-2.5 font-mono text-[10.5px] text-ink outline-none transition-colors placeholder:text-faint focus:border-accent/50 disabled:opacity-60"
302
347
  />
303
- <p className="mt-1 text-[9px] leading-relaxed text-faint">
304
- {workspaceAvailable ? `Open workspace: ${workspaceName}` : 'No workspace open.'} Another folder must
305
- already exist on this machine.
306
- </p>
348
+ <div className="mt-1.5 flex items-center gap-2">
349
+ <p className="min-w-0 flex-1 truncate text-[9px] leading-relaxed text-faint">
350
+ {workspaceAvailable ? `Open workspace: ${workspaceName}` : 'No workspace open.'} Any folder that already exists on this machine.
351
+ </p>
352
+ <button
353
+ type="button"
354
+ onClick={() => void detect()}
355
+ disabled={running || inspecting || !path.trim()}
356
+ className="inline-flex h-6 shrink-0 items-center gap-1 rounded-md border border-line bg-panel px-1.5 text-[9px] font-medium text-muted hover:text-ink disabled:opacity-40"
357
+ >
358
+ {inspecting ? <Spinner size={9} /> : <Sparkles size={9} />}
359
+ Detect
360
+ </button>
361
+ </div>
362
+ {inspection && (
363
+ <div className="mt-2 flex flex-wrap items-center gap-1.5 rounded-lg border border-line bg-raised/40 px-2 py-1.5 text-[9px] text-muted">
364
+ <span className="rounded bg-panel px-1.5 py-0.5 font-medium text-ink">{inspection.framework}</span>
365
+ {inspection.existingOutputs.length > 0 && <span>build output: {inspection.existingOutputs.join(', ')}</span>}
366
+ {!inspection.hasPackageJson && <span>static folder, no package.json</span>}
367
+ </div>
368
+ )}
307
369
  </div>
308
370
 
309
371
  <div className="grid grid-cols-2 gap-2.5">
@@ -316,7 +378,7 @@ export function DeployModal() {
316
378
  value={outputDir}
317
379
  onChange={(event) => setOutputDir(event.target.value)}
318
380
  disabled={running}
319
- placeholder="dist"
381
+ placeholder="auto"
320
382
  spellCheck={false}
321
383
  className="h-8 w-full rounded-lg border border-line bg-panel px-2 font-mono text-[10.5px] text-ink outline-none focus:border-accent/50 disabled:opacity-60"
322
384
  />
@@ -338,13 +400,13 @@ export function DeployModal() {
338
400
  </div>
339
401
 
340
402
  <div className="flex flex-wrap gap-1">
341
- {['dist', 'build', 'out', '.'].map((value) => (
403
+ {['auto', 'dist', 'build', 'out', '.'].map((value) => (
342
404
  <button
343
405
  key={value}
344
406
  type="button"
345
407
  onClick={() => setOutputDir(value)}
346
408
  disabled={running}
347
- className={cn('chip h-7 px-2 text-[9.5px]', outputDir === value && 'chip-active')}
409
+ className={cn('chip h-7 px-2 text-[9.5px]', (outputDir || 'auto') === value && 'chip-active')}
348
410
  >
349
411
  {value}
350
412
  </button>
@@ -0,0 +1,108 @@
1
+ import type { LucideIcon } from 'lucide-react'
2
+ import {
3
+ Braces,
4
+ FileArchive,
5
+ FileAudio,
6
+ FileCode2,
7
+ FileCog,
8
+ FileImage,
9
+ FileKey2,
10
+ FileLock2,
11
+ FileText,
12
+ FileVideo,
13
+ Folder,
14
+ FolderOpen,
15
+ Palette,
16
+ Terminal,
17
+ } from 'lucide-react'
18
+ import { cn } from '../lib/utils'
19
+
20
+ const TYPES: Record<string, { icon: LucideIcon; tone: string }> = {
21
+ ts: { icon: FileCode2, tone: 'text-sky-500' },
22
+ tsx: { icon: FileCode2, tone: 'text-sky-500' },
23
+ js: { icon: FileCode2, tone: 'text-amber-400' },
24
+ jsx: { icon: FileCode2, tone: 'text-amber-400' },
25
+ mjs: { icon: FileCode2, tone: 'text-amber-400' },
26
+ cjs: { icon: FileCode2, tone: 'text-amber-400' },
27
+ rs: { icon: FileCode2, tone: 'text-orange-400' },
28
+ go: { icon: FileCode2, tone: 'text-cyan-400' },
29
+ py: { icon: FileCode2, tone: 'text-emerald-400' },
30
+ rb: { icon: FileCode2, tone: 'text-rose-400' },
31
+ java: { icon: FileCode2, tone: 'text-red-400' },
32
+ css: { icon: Palette, tone: 'text-fuchsia-400' },
33
+ scss: { icon: Palette, tone: 'text-pink-400' },
34
+ sass: { icon: Palette, tone: 'text-pink-400' },
35
+ less: { icon: Palette, tone: 'text-pink-400' },
36
+ html: { icon: FileCode2, tone: 'text-orange-500' },
37
+ vue: { icon: FileCode2, tone: 'text-emerald-500' },
38
+ svelte: { icon: FileCode2, tone: 'text-orange-500' },
39
+ json: { icon: Braces, tone: 'text-yellow-500' },
40
+ jsonc: { icon: Braces, tone: 'text-yellow-500' },
41
+ yaml: { icon: FileCog, tone: 'text-teal-400' },
42
+ yml: { icon: FileCog, tone: 'text-teal-400' },
43
+ toml: { icon: FileCog, tone: 'text-teal-400' },
44
+ ini: { icon: FileCog, tone: 'text-teal-400' },
45
+ env: { icon: FileKey2, tone: 'text-amber-500' },
46
+ md: { icon: FileText, tone: 'text-violet-400' },
47
+ mdx: { icon: FileText, tone: 'text-violet-400' },
48
+ txt: { icon: FileText, tone: 'text-slate-400' },
49
+ csv: { icon: FileText, tone: 'text-emerald-400' },
50
+ svg: { icon: FileImage, tone: 'text-purple-400' },
51
+ png: { icon: FileImage, tone: 'text-purple-400' },
52
+ jpg: { icon: FileImage, tone: 'text-purple-400' },
53
+ jpeg: { icon: FileImage, tone: 'text-purple-400' },
54
+ gif: { icon: FileImage, tone: 'text-purple-400' },
55
+ webp: { icon: FileImage, tone: 'text-purple-400' },
56
+ ico: { icon: FileImage, tone: 'text-purple-400' },
57
+ mp3: { icon: FileAudio, tone: 'text-rose-400' },
58
+ wav: { icon: FileAudio, tone: 'text-rose-400' },
59
+ mp4: { icon: FileVideo, tone: 'text-rose-400' },
60
+ mov: { icon: FileVideo, tone: 'text-rose-400' },
61
+ zip: { icon: FileArchive, tone: 'text-amber-500' },
62
+ gz: { icon: FileArchive, tone: 'text-amber-500' },
63
+ tar: { icon: FileArchive, tone: 'text-amber-500' },
64
+ lock: { icon: FileLock2, tone: 'text-slate-400' },
65
+ sh: { icon: Terminal, tone: 'text-emerald-400' },
66
+ bash: { icon: Terminal, tone: 'text-emerald-400' },
67
+ zsh: { icon: Terminal, tone: 'text-emerald-400' },
68
+ sql: { icon: FileText, tone: 'text-cyan-400' },
69
+ }
70
+
71
+ const SPECIAL: Array<{ match: RegExp; icon: LucideIcon; tone: string }> = [
72
+ { match: /^(dockerfile|makefile|justfile)$/i, icon: FileCog, tone: 'text-sky-400' },
73
+ { match: /^\.env/i, icon: FileKey2, tone: 'text-amber-500' },
74
+ { match: /^(\.gitignore|\.gitattributes|\.npmignore|\.dockerignore)$/i, icon: FileCog, tone: 'text-slate-400' },
75
+ { match: /^package(-lock)?\.json$/i, icon: Braces, tone: 'text-emerald-400' },
76
+ { match: /^(vite|webpack|rollup|tsconfig|tailwind|postcss)\./i, icon: FileCog, tone: 'text-sky-400' },
77
+ { match: /^readme/i, icon: FileText, tone: 'text-violet-400' },
78
+ { match: /^skill\.md$/i, icon: FileText, tone: 'text-accent' },
79
+ ]
80
+
81
+ export function fileTone(path: string): { icon: LucideIcon; tone: string } {
82
+ const name = path.split('/').pop() ?? path
83
+ const special = SPECIAL.find((entry) => entry.match.test(name))
84
+ if (special) return { icon: special.icon, tone: special.tone }
85
+ const extension = /\.([a-z0-9]+)$/i.exec(name)?.[1]?.toLowerCase() ?? ''
86
+ return TYPES[extension] ?? { icon: FileText, tone: 'text-faint' }
87
+ }
88
+
89
+ export function FileIcon({
90
+ path,
91
+ type = 'file',
92
+ open = false,
93
+ size = 13,
94
+ className,
95
+ }: {
96
+ path: string
97
+ type?: 'file' | 'dir' | 'link'
98
+ open?: boolean
99
+ size?: number
100
+ className?: string
101
+ }) {
102
+ if (type === 'dir') {
103
+ const Icon = open ? FolderOpen : Folder
104
+ return <Icon size={size} className={cn('shrink-0 fill-amber-400/25 text-amber-500', className)} />
105
+ }
106
+ const { icon: Icon, tone } = fileTone(path)
107
+ return <Icon size={size} className={cn('shrink-0', tone, className)} />
108
+ }
@@ -1,10 +1,10 @@
1
1
  import { useMemo, useState } from 'react'
2
- import { Activity, CalendarDays, Clock3, Cpu, MessageSquare, Sparkles } from 'lucide-react'
2
+ import { Activity, CalendarDays, Clock3, Cpu, CreditCard, MessageSquare, Sparkles } from 'lucide-react'
3
3
  import { useChats } from '../store/chats'
4
4
  import { useSettings } from '../store/settings'
5
5
  import { useWorkspace } from '../store/workspace'
6
6
  import { cn } from '../lib/utils'
7
- import { computeUsage, formatTokens, shortModelName, type UsageRange } from '../lib/usage'
7
+ import { computeUsage, formatCredits, formatTokens, shortModelName, type UsageRange } from '../lib/usage'
8
8
  import { PixleSol } from './PixleSol'
9
9
 
10
10
  const RANGES: Array<{ value: UsageRange; label: string }> = [
@@ -118,6 +118,7 @@ export function HomeDashboard() {
118
118
  <Stat icon={Activity} label="Sessions" value={String(stats.sessions)} />
119
119
  <Stat icon={MessageSquare} label="Messages" value={String(stats.messages)} />
120
120
  <Stat icon={Sparkles} label="Total tokens" value={stats.tokens ? formatTokens(stats.tokens) : '0'} />
121
+ <Stat icon={CreditCard} label="Credits spent" value={formatCredits(stats.credits)} />
121
122
  <Stat icon={CalendarDays} label="Active days" value={String(stats.activeDays)} />
122
123
  <Stat icon={Clock3} label="Peak hour" value={stats.peakHour} />
123
124
  <Stat icon={Cpu} label="Favorite model" value={shortModelName(stats.favoriteModel)} />
@@ -143,6 +144,12 @@ export function HomeDashboard() {
143
144
  {stats.messages === 0
144
145
  ? 'No activity in this range yet. Send a message to start the graph.'
145
146
  : `You've used ~${formatTokens(stats.tokens)} tokens across ${stats.messages} messages.`}
147
+ {stats.credits > 0 && (
148
+ <>
149
+ <span className="px-1.5 text-line">·</span>
150
+ {formatCredits(stats.credits)} in provider credits
151
+ </>
152
+ )}
146
153
  {stats.favoriteModel !== '—' && (
147
154
  <>
148
155
  <span className="px-1.5 text-line">·</span>
@@ -1,6 +1,6 @@
1
1
  import { cn } from '../lib/utils'
2
2
 
3
- /** The OpenSol sun: a solid core with eight round-capped rays. Inherits `currentColor`. */
3
+ /** The OpenSol aperture: a broken solar ring around a solid core. Inherits `currentColor`. */
4
4
  export function LogoMark({ size = 28, className, spin = false }: { size?: number; className?: string; spin?: boolean }) {
5
5
  return (
6
6
  <span
@@ -9,10 +9,16 @@ export function LogoMark({ size = 28, className, spin = false }: { size?: number
9
9
  aria-hidden="true"
10
10
  >
11
11
  <svg viewBox="0 0 32 32" width={size} height={size} fill="none" className={cn(spin && 'animate-spin-slow')}>
12
- <circle cx="16" cy="16" r="6.2" fill="currentColor" />
13
- <g stroke="currentColor" strokeWidth="2.4" strokeLinecap="round">
14
- <path d="M16 3.6v3.2M16 25.2v3.2M3.6 16h3.2M25.2 16h3.2" />
15
- <path d="M7.2 7.2l2.2 2.2M22.6 22.6l2.2 2.2M7.2 24.8l2.2-2.2M22.6 9.4l2.2-2.2" opacity="0.55" />
12
+ <circle cx="16" cy="16" r="5.1" fill="currentColor" />
13
+ <circle cx="16" cy="16" r="8.4" stroke="currentColor" strokeWidth="0.9" opacity="0.22" />
14
+ <g stroke="currentColor" strokeWidth="2.15" strokeLinecap="round">
15
+ <path d="M16 4.4a11.6 11.6 0 0 1 9.7 5.5" />
16
+ <path d="M27.6 16a11.6 11.6 0 0 1-5.5 9.7" opacity="0.6" />
17
+ <path d="M16 27.6a11.6 11.6 0 0 1-9.7-5.5" opacity="0.32" />
18
+ <path d="M4.4 16a11.6 11.6 0 0 1 5.5-9.7" opacity="0.8" />
19
+ </g>
20
+ <g stroke="currentColor" strokeWidth="1.7" strokeLinecap="round">
21
+ <path d="M16 1.5v2.2M30.5 16h-2.2M16 30.5v-2.2M1.5 16h2.2" opacity="0.9" />
16
22
  </g>
17
23
  </svg>
18
24
  </span>
@@ -178,6 +178,8 @@ export function ModelDeck({ open, onClose }: { open: boolean; onClose: () => voi
178
178
  const [manual, setManual] = useState('')
179
179
  const [showManual, setShowManual] = useState(false)
180
180
  const [cursor, setCursor] = useState(0)
181
+ /** Only keyboard moves scroll the list; scrolling on hover fights the pointer and jitters. */
182
+ const keyboardCursor = useRef(true)
181
183
  const listRef = useRef<HTMLDivElement>(null)
182
184
  const searchRef = useRef<HTMLInputElement>(null)
183
185
 
@@ -226,11 +228,13 @@ export function ModelDeck({ open, onClose }: { open: boolean; onClose: () => voi
226
228
  }, [models, query])
227
229
 
228
230
  useLayoutEffect(() => {
231
+ keyboardCursor.current = true
229
232
  setCursor(0)
230
233
  }, [query, provider])
231
234
 
232
235
  useEffect(() => {
233
- listRef.current?.querySelector<HTMLElement>(`[data-row="${cursor}"]`)?.scrollIntoView({ block: 'center' })
236
+ if (!keyboardCursor.current) return
237
+ listRef.current?.querySelector<HTMLElement>(`[data-row="${cursor}"]`)?.scrollIntoView({ block: 'nearest' })
234
238
  }, [cursor])
235
239
 
236
240
  const choose = (model: ModelInfo) => {
@@ -258,9 +262,11 @@ export function ModelDeck({ open, onClose }: { open: boolean; onClose: () => voi
258
262
  const onSearchKey = (event: KeyboardEvent<HTMLInputElement>) => {
259
263
  if (event.key === 'ArrowDown') {
260
264
  event.preventDefault()
265
+ keyboardCursor.current = true
261
266
  setCursor((value) => Math.min(filtered.length - 1, value + 1))
262
267
  } else if (event.key === 'ArrowUp') {
263
268
  event.preventDefault()
269
+ keyboardCursor.current = true
264
270
  setCursor((value) => Math.max(0, value - 1))
265
271
  } else if (event.key === 'Enter') {
266
272
  event.preventDefault()
@@ -472,7 +478,11 @@ export function ModelDeck({ open, onClose }: { open: boolean; onClose: () => voi
472
478
  setCursor(i)
473
479
  choose(model)
474
480
  }}
475
- onMouseMove={() => highlighted || setCursor(i)}
481
+ onMouseEnter={() => {
482
+ if (highlighted) return
483
+ keyboardCursor.current = false
484
+ setCursor(i)
485
+ }}
476
486
  style={{ animationDelay: `${Math.min(i * 22, 260)}ms` }}
477
487
  className={cn(
478
488
  'deck-row group/row relative mb-1 flex w-full items-center gap-3 overflow-hidden rounded-2xl px-3 py-2.5 text-left transition-colors',