@bibobonking/opensol 3.0.2 → 3.1.0
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 +60 -8
- package/CHANGELOG.md +18 -0
- package/README.md +15 -12
- package/desktop/README.md +39 -0
- package/desktop/loading/index.html +36 -0
- package/desktop/src-tauri/Cargo.lock +4301 -0
- package/desktop/src-tauri/Cargo.toml +23 -0
- package/desktop/src-tauri/binaries/.gitkeep +0 -0
- package/desktop/src-tauri/build.rs +3 -0
- package/desktop/src-tauri/capabilities/default.json +7 -0
- package/desktop/src-tauri/icons/128x128.png +0 -0
- package/desktop/src-tauri/icons/128x128@2x.png +0 -0
- package/desktop/src-tauri/icons/32x32.png +0 -0
- package/desktop/src-tauri/icons/64x64.png +0 -0
- package/desktop/src-tauri/icons/Square107x107Logo.png +0 -0
- package/desktop/src-tauri/icons/Square142x142Logo.png +0 -0
- package/desktop/src-tauri/icons/Square150x150Logo.png +0 -0
- package/desktop/src-tauri/icons/Square284x284Logo.png +0 -0
- package/desktop/src-tauri/icons/Square30x30Logo.png +0 -0
- package/desktop/src-tauri/icons/Square310x310Logo.png +0 -0
- package/desktop/src-tauri/icons/Square44x44Logo.png +0 -0
- package/desktop/src-tauri/icons/Square71x71Logo.png +0 -0
- package/desktop/src-tauri/icons/Square89x89Logo.png +0 -0
- package/desktop/src-tauri/icons/StoreLogo.png +0 -0
- package/desktop/src-tauri/icons/icon.icns +0 -0
- package/desktop/src-tauri/icons/icon.ico +0 -0
- package/desktop/src-tauri/icons/icon.png +0 -0
- package/desktop/src-tauri/src/lib.rs +210 -0
- package/desktop/src-tauri/src/main.rs +6 -0
- package/desktop/src-tauri/tauri.conf.json +48 -0
- package/dist/assets/index-DdI1_iPD.css +1 -0
- package/dist/assets/index-egG5Bgfo.js +312 -0
- package/dist/index.html +2 -2
- package/package.json +2 -1
- package/server/daemon.mjs +64 -2
- package/server/lib/mcp.mjs +532 -0
- package/server/lib/mcp.test.mjs +46 -0
- package/server/lib/store.mjs +22 -0
- package/server/lib/tools.mjs +59 -1
- package/src/agent/tools.ts +6 -5
- package/src/components/Composer.tsx +22 -38
- package/src/components/DiffView.tsx +98 -0
- package/src/components/McpTab.tsx +337 -0
- package/src/components/MessageItem.tsx +62 -13
- package/src/components/ModelDeck.tsx +652 -0
- package/src/components/SessionsTab.tsx +206 -0
- package/src/components/SettingsModal.tsx +106 -69
- package/src/components/Sidebar.tsx +78 -24
- package/src/components/ToolCallCard.tsx +29 -7
- package/src/components/WorkspacePanel.tsx +16 -1
- package/src/core/kernel.ts +2 -0
- package/src/core/mcp-tools.ts +63 -0
- package/src/core/plugins/tools/fs.ts +24 -3
- package/src/core/services/agent-loop.ts +3 -1
- package/src/core/services/approval.ts +2 -1
- package/src/core/services/titles.ts +135 -0
- package/src/core/services/tools.ts +11 -8
- package/src/core/types.ts +4 -0
- package/src/index.css +97 -1
- package/src/lib/daemon.ts +29 -0
- package/src/lib/diff.ts +62 -0
- package/src/lib/permissions.ts +6 -6
- package/src/store/chats.ts +9 -1
- package/src/store/mcp.ts +67 -0
- package/src/store/settings.ts +9 -6
- package/src/store/ui.ts +13 -0
- package/src/store/workspace.ts +26 -0
- package/src/types.ts +51 -2
- package/tsconfig.tsbuildinfo +1 -1
- package/dist/assets/index-BmN7pBji.css +0 -1
- package/dist/assets/index-DgNDjkOz.js +0 -301
- package/src/components/ModelPicker.tsx +0 -487
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import assert from 'node:assert/strict'
|
|
2
|
+
import { mkdtemp, writeFile } from 'node:fs/promises'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import { McpManager, functionName, sanitizeServerConfig } from './mcp.mjs'
|
|
7
|
+
import { lineDiffStats } from './tools.mjs'
|
|
8
|
+
|
|
9
|
+
const SERVER = `
|
|
10
|
+
import readline from 'node:readline'
|
|
11
|
+
const send = (m) => process.stdout.write(JSON.stringify({ jsonrpc: '2.0', ...m }) + '\\n')
|
|
12
|
+
readline.createInterface({ input: process.stdin }).on('line', (line) => {
|
|
13
|
+
const m = JSON.parse(line)
|
|
14
|
+
if (m.method === 'initialize') send({ id: m.id, result: { protocolVersion: '2025-03-26', capabilities: {}, serverInfo: { name: 'echo', version: '1' } } })
|
|
15
|
+
else if (m.method === 'tools/list') send({ id: m.id, result: { tools: [{ name: 'echo', description: 'Echo', inputSchema: { type: 'object', properties: {} } }] } })
|
|
16
|
+
else if (m.method === 'tools/call') send({ id: m.id, result: { content: [{ type: 'text', text: 'echo: ' + m.params.arguments.text }] } })
|
|
17
|
+
})`
|
|
18
|
+
|
|
19
|
+
test('an stdio MCP server connects, lists tools and answers a call', async () => {
|
|
20
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), 'opensol-mcp-'))
|
|
21
|
+
const file = path.join(dir, 'server.mjs')
|
|
22
|
+
await writeFile(file, SERVER)
|
|
23
|
+
const manager = new McpManager()
|
|
24
|
+
const config = sanitizeServerConfig({ name: 'Echo Server', command: process.execPath, args: [file] })
|
|
25
|
+
await manager.setConfigs([config])
|
|
26
|
+
const view = await manager.connect(config.id)
|
|
27
|
+
assert.equal(view.status, 'connected', view.error)
|
|
28
|
+
assert.equal(view.tools[0].fn, functionName(config.id, 'echo'))
|
|
29
|
+
const result = await manager.callTool(config.id, 'echo', { text: 'hi' })
|
|
30
|
+
assert.deepEqual(result, { text: 'echo: hi', isError: false })
|
|
31
|
+
await manager.shutdown()
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
test('secrets are masked in the public view and kept on save', () => {
|
|
35
|
+
const manager = new McpManager()
|
|
36
|
+
const config = sanitizeServerConfig({ name: 'x', command: 'node', env: { TOKEN: 'secret' } })
|
|
37
|
+
manager.configs.set(config.id, config)
|
|
38
|
+
assert.equal(manager.list()[0].env.TOKEN.includes('secret'), false)
|
|
39
|
+
const again = sanitizeServerConfig({ ...manager.list()[0] }, config)
|
|
40
|
+
assert.equal(again.env.TOKEN, 'secret')
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
test('line diff stats count added and removed lines', () => {
|
|
44
|
+
assert.deepEqual(lineDiffStats('a\nb\nc', 'a\nB\nc\nd'), { added: 2, removed: 1 })
|
|
45
|
+
assert.deepEqual(lineDiffStats('same', 'same'), { added: 0, removed: 0 })
|
|
46
|
+
})
|
package/server/lib/store.mjs
CHANGED
|
@@ -31,6 +31,7 @@ const DEFAULT_STATE = () => ({
|
|
|
31
31
|
chats: { sessions: [], activeId: null },
|
|
32
32
|
workspaces: [],
|
|
33
33
|
activeWorkspace: null,
|
|
34
|
+
mcpServers: [],
|
|
34
35
|
legacyMigratedAt: null,
|
|
35
36
|
})
|
|
36
37
|
|
|
@@ -130,6 +131,7 @@ export async function loadState() {
|
|
|
130
131
|
.slice(0, 20)
|
|
131
132
|
: [],
|
|
132
133
|
activeWorkspace: typeof parsed.activeWorkspace === 'string' ? parsed.activeWorkspace : null,
|
|
134
|
+
mcpServers: Array.isArray(parsed.mcpServers) ? parsed.mcpServers.filter((item) => item && typeof item.id === 'string') : [],
|
|
133
135
|
}
|
|
134
136
|
return stateCache
|
|
135
137
|
}
|
|
@@ -348,6 +350,26 @@ export async function addWorkspace(rawPath) {
|
|
|
348
350
|
return entry
|
|
349
351
|
}
|
|
350
352
|
|
|
353
|
+
/** Drop one folder from the saved list. The folder itself is never touched on disk. */
|
|
354
|
+
export async function removeWorkspace(rawPath) {
|
|
355
|
+
const state = await loadState()
|
|
356
|
+
const resolved = path.resolve(rawPath)
|
|
357
|
+
const workspaces = state.workspaces.filter((item) => item.path !== resolved)
|
|
358
|
+
const activeWorkspace = state.activeWorkspace === resolved ? null : state.activeWorkspace
|
|
359
|
+
await saveState({ ...state, workspaces, activeWorkspace })
|
|
360
|
+
return workspaces.length !== state.workspaces.length
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export async function getMcpServers() {
|
|
364
|
+
const state = await loadState()
|
|
365
|
+
return state.mcpServers ?? []
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export async function saveMcpServers(mcpServers) {
|
|
369
|
+
const state = await loadState()
|
|
370
|
+
await saveState({ ...state, mcpServers })
|
|
371
|
+
}
|
|
372
|
+
|
|
351
373
|
export async function forgetWorkspace() {
|
|
352
374
|
const state = await loadState()
|
|
353
375
|
await saveState({ ...state, activeWorkspace: null })
|
package/server/lib/tools.mjs
CHANGED
|
@@ -207,6 +207,45 @@ export async function readWorkspaceFile(input, { maxBytes = MAX_READ } = {}) {
|
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
209
|
|
|
210
|
+
|
|
211
|
+
/** Line-level diff counts (LCS) used to report +added / -removed after a file change. */
|
|
212
|
+
export function lineDiffStats(before, after) {
|
|
213
|
+
if (before === after) return { added: 0, removed: 0 }
|
|
214
|
+
const a = before === '' ? [] : before.split('\n')
|
|
215
|
+
const b = after === '' ? [] : after.split('\n')
|
|
216
|
+
let start = 0
|
|
217
|
+
while (start < a.length && start < b.length && a[start] === b[start]) start += 1
|
|
218
|
+
let endA = a.length
|
|
219
|
+
let endB = b.length
|
|
220
|
+
while (endA > start && endB > start && a[endA - 1] === b[endB - 1]) {
|
|
221
|
+
endA -= 1
|
|
222
|
+
endB -= 1
|
|
223
|
+
}
|
|
224
|
+
const x = a.slice(start, endA)
|
|
225
|
+
const y = b.slice(start, endB)
|
|
226
|
+
if (x.length === 0 || y.length === 0 || x.length * y.length > 4_000_000) {
|
|
227
|
+
return { added: y.length, removed: x.length }
|
|
228
|
+
}
|
|
229
|
+
let previous = new Uint32Array(y.length + 1)
|
|
230
|
+
for (let i = 1; i <= x.length; i += 1) {
|
|
231
|
+
const row = new Uint32Array(y.length + 1)
|
|
232
|
+
for (let j = 1; j <= y.length; j += 1) {
|
|
233
|
+
row[j] = x[i - 1] === y[j - 1] ? previous[j - 1] + 1 : Math.max(previous[j], row[j - 1])
|
|
234
|
+
}
|
|
235
|
+
previous = row
|
|
236
|
+
}
|
|
237
|
+
const common = previous[y.length]
|
|
238
|
+
return { added: y.length - common, removed: x.length - common }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** 1-based line and column of a character offset. */
|
|
242
|
+
function positionOf(text, offset) {
|
|
243
|
+
const head = text.slice(0, offset)
|
|
244
|
+
const line = head.split('\n').length
|
|
245
|
+
const column = offset - (head.lastIndexOf('\n') + 1) + 1
|
|
246
|
+
return { line, column }
|
|
247
|
+
}
|
|
248
|
+
|
|
210
249
|
export async function writeWorkspaceFile(input, content, { create = true } = {}) {
|
|
211
250
|
const { root, absolute, relative } = await resolveWorkspacePath(input, { allowRoot: false })
|
|
212
251
|
if (typeof content !== 'string') throw new HttpError(400, 'File content must be text.', 'bad_content')
|
|
@@ -216,6 +255,10 @@ export async function writeWorkspaceFile(input, content, { create = true } = {})
|
|
|
216
255
|
const existing = await stat(absolute).catch(() => null)
|
|
217
256
|
if (!existing && !create) throw new HttpError(404, `File not found: ${relative}`, 'not_found')
|
|
218
257
|
if (existing?.isDirectory()) throw new HttpError(400, `${relative} is a folder.`, 'is_directory')
|
|
258
|
+
let previousText = ''
|
|
259
|
+
if (existing && existing.size <= MAX_READ) {
|
|
260
|
+
previousText = await readFile(absolute, 'utf8').catch(() => '')
|
|
261
|
+
}
|
|
219
262
|
await mkdir(path.dirname(absolute), { recursive: true })
|
|
220
263
|
const temp = `${absolute}.${process.pid}.maxcode.tmp`
|
|
221
264
|
await writeFile(temp, content, 'utf8')
|
|
@@ -235,9 +278,17 @@ export async function writeWorkspaceFile(input, content, { create = true } = {})
|
|
|
235
278
|
created: !existing,
|
|
236
279
|
bytes: info.size,
|
|
237
280
|
hash: snapshot(Buffer.from(content, 'utf8')).hash,
|
|
281
|
+
diff: { ...lineDiffStats(previousText, content), ...positionOfFirstChange(previousText, content) },
|
|
238
282
|
}
|
|
239
283
|
}
|
|
240
284
|
|
|
285
|
+
function positionOfFirstChange(before, after) {
|
|
286
|
+
let index = 0
|
|
287
|
+
const limit = Math.min(before.length, after.length)
|
|
288
|
+
while (index < limit && before[index] === after[index]) index += 1
|
|
289
|
+
return positionOf(after, index)
|
|
290
|
+
}
|
|
291
|
+
|
|
241
292
|
export async function editWorkspaceFile(input, oldText, newText, { replaceAll = false } = {}) {
|
|
242
293
|
if (typeof oldText !== 'string' || !oldText) {
|
|
243
294
|
throw new HttpError(400, 'The text to replace is empty.', 'bad_edit')
|
|
@@ -261,7 +312,14 @@ export async function editWorkspaceFile(input, oldText, newText, { replaceAll =
|
|
|
261
312
|
? file.content.split(oldText).join(newText)
|
|
262
313
|
: file.content.replace(oldText, () => newText)
|
|
263
314
|
const written = await writeWorkspaceFile(input, next)
|
|
264
|
-
|
|
315
|
+
const count = replaceAll ? occurrences : 1
|
|
316
|
+
const per = lineDiffStats(oldText, newText)
|
|
317
|
+
const first = positionOf(file.content, file.content.indexOf(oldText))
|
|
318
|
+
return {
|
|
319
|
+
...written,
|
|
320
|
+
replacements: count,
|
|
321
|
+
diff: { added: per.added * count, removed: per.removed * count, line: first.line, column: first.column },
|
|
322
|
+
}
|
|
265
323
|
}
|
|
266
324
|
|
|
267
325
|
export async function createWorkspaceEntry(input, type = 'file', content = '') {
|
package/src/agent/tools.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentMode, AppSettings, ToolName } from '../types'
|
|
1
|
+
import type { AgentMode, AppSettings, BuiltinToolName, ToolName } from '../types'
|
|
2
2
|
import { executeDaemonTool } from '../lib/daemon'
|
|
3
3
|
import { useWorkspace } from '../store/workspace'
|
|
4
4
|
import { kernel } from '../core/kernel'
|
|
@@ -239,7 +239,7 @@ export function getToolSchemas(
|
|
|
239
239
|
}
|
|
240
240
|
|
|
241
241
|
return tools.filter((tool) => {
|
|
242
|
-
if (!settings.tools[tool.name]?.enabled) return false
|
|
242
|
+
if (!settings.tools[tool.name as BuiltinToolName]?.enabled) return false
|
|
243
243
|
if (!toolAllowedInMode(tool.name, mode)) return false
|
|
244
244
|
if ((tool.name === 'web_search' || tool.name === 'read_url') && !settings.webSearchEnabled) return false
|
|
245
245
|
return true
|
|
@@ -481,7 +481,7 @@ export async function executeTool(
|
|
|
481
481
|
if (kernel.tools) {
|
|
482
482
|
return await kernel.tools.execute(name, args, settings, mode, signal)
|
|
483
483
|
}
|
|
484
|
-
if (!settings.tools[name]?.enabled) {
|
|
484
|
+
if (!settings.tools[name as BuiltinToolName]?.enabled) {
|
|
485
485
|
return { output: 'This tool is disabled in Settings → Tools.', isError: true }
|
|
486
486
|
}
|
|
487
487
|
if (!toolAllowedInMode(name, mode)) {
|
|
@@ -506,7 +506,8 @@ export async function executeTool(
|
|
|
506
506
|
}
|
|
507
507
|
|
|
508
508
|
export function toolLabel(name: ToolName): string {
|
|
509
|
-
|
|
509
|
+
if (name.startsWith('mcp__')) return name.split('__').slice(2).join(' ').replace(/_/g, ' ') || 'MCP tool'
|
|
510
|
+
const labels: Record<BuiltinToolName, string> = {
|
|
510
511
|
shell: 'Shell',
|
|
511
512
|
read_file: 'Read file',
|
|
512
513
|
list_files: 'List files',
|
|
@@ -519,7 +520,7 @@ export function toolLabel(name: ToolName): string {
|
|
|
519
520
|
git: 'Git',
|
|
520
521
|
subagent: 'Subagent',
|
|
521
522
|
}
|
|
522
|
-
return labels[name] ?? 'Tool'
|
|
523
|
+
return labels[name as BuiltinToolName] ?? 'Tool'
|
|
523
524
|
}
|
|
524
525
|
|
|
525
526
|
export function toolSummary(name: ToolName, args: Record<string, unknown>): string {
|
|
@@ -9,10 +9,8 @@ import {
|
|
|
9
9
|
type KeyboardEvent,
|
|
10
10
|
} from 'react'
|
|
11
11
|
import {
|
|
12
|
-
Brain,
|
|
13
12
|
Check,
|
|
14
13
|
ChevronDown,
|
|
15
|
-
Cpu,
|
|
16
14
|
FileText,
|
|
17
15
|
FolderOpen,
|
|
18
16
|
Globe2,
|
|
@@ -25,17 +23,16 @@ import {
|
|
|
25
23
|
Upload,
|
|
26
24
|
X,
|
|
27
25
|
} from 'lucide-react'
|
|
28
|
-
import type { Attachment
|
|
26
|
+
import type { Attachment } from '../types'
|
|
29
27
|
import { activeModelId, hasCredentials, useSettings } from '../store/settings'
|
|
30
28
|
import { useActiveModel, useProviderModels } from '../store/models'
|
|
31
29
|
import { useUi } from '../store/ui'
|
|
32
30
|
import { useWorkspace } from '../store/workspace'
|
|
33
|
-
import { effortLabel } from '../data/providers'
|
|
34
31
|
import { effortOptions } from '../lib/reasoning'
|
|
35
32
|
import { PERMISSION_MODES, permissionLabel, permissionMode, permissionTools } from '../lib/permissions'
|
|
36
33
|
import { useClickOutside } from '../lib/hooks'
|
|
37
34
|
import { cn, formatBytes, readFileAsDataUrl, readFileAsText } from '../lib/utils'
|
|
38
|
-
import {
|
|
35
|
+
import { ModelDeck, ModelDeckTrigger } from './ModelDeck'
|
|
39
36
|
import { PixleSol } from './PixleSol'
|
|
40
37
|
import type { PixleMood } from '../lib/pixlesol'
|
|
41
38
|
|
|
@@ -109,6 +106,17 @@ export function Composer({
|
|
|
109
106
|
const permissionRef = useRef<HTMLDivElement>(null)
|
|
110
107
|
const permissionTriggerRef = useRef<HTMLButtonElement>(null)
|
|
111
108
|
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
const onKey = (event: globalThis.KeyboardEvent) => {
|
|
111
|
+
if ((event.metaKey || event.ctrlKey) && !event.shiftKey && !event.altKey && event.key.toLowerCase() === 'm') {
|
|
112
|
+
event.preventDefault()
|
|
113
|
+
setPickerOpen((open) => !open)
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
window.addEventListener('keydown', onKey)
|
|
117
|
+
return () => window.removeEventListener('keydown', onKey)
|
|
118
|
+
}, [])
|
|
119
|
+
|
|
112
120
|
useClickOutside(permissionRef, () => setPermissionOpen(false), permissionOpen, permissionTriggerRef)
|
|
113
121
|
|
|
114
122
|
const modelId = activeModelId(settings)
|
|
@@ -341,7 +349,7 @@ export function Composer({
|
|
|
341
349
|
|
|
342
350
|
return (
|
|
343
351
|
<div className="relative w-full">
|
|
344
|
-
<
|
|
352
|
+
<ModelDeck open={pickerOpen && connected} onClose={() => setPickerOpen(false)} />
|
|
345
353
|
|
|
346
354
|
{commandMatches.length > 0 && (
|
|
347
355
|
<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">
|
|
@@ -401,25 +409,6 @@ export function Composer({
|
|
|
401
409
|
<Globe2 size={12} />
|
|
402
410
|
Search
|
|
403
411
|
</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
412
|
{activeSkills > 0 && (
|
|
424
413
|
<button
|
|
425
414
|
type="button"
|
|
@@ -592,21 +581,16 @@ export function Composer({
|
|
|
592
581
|
{contextPct}%
|
|
593
582
|
</span>
|
|
594
583
|
)}
|
|
595
|
-
<
|
|
596
|
-
|
|
597
|
-
|
|
584
|
+
<ModelDeckTrigger
|
|
585
|
+
triggerRef={modelTriggerRef}
|
|
586
|
+
label={modelLabel}
|
|
587
|
+
open={pickerOpen}
|
|
598
588
|
disabled={!connected}
|
|
599
589
|
onClick={() => setPickerOpen((open) => !open)}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
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>
|
|
590
|
+
efforts={efforts}
|
|
591
|
+
effort={settings.effort}
|
|
592
|
+
title={connected ? 'Model deck: choose model and reasoning effort (Ctrl M)' : 'Add a provider key in Settings first'}
|
|
593
|
+
/>
|
|
610
594
|
{streaming ? (
|
|
611
595
|
<button
|
|
612
596
|
type="button"
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import { diffLines } from '../lib/diff'
|
|
3
|
+
import { cn } from '../lib/utils'
|
|
4
|
+
|
|
5
|
+
const MAX_ROWS = 120
|
|
6
|
+
|
|
7
|
+
/** Compact unified diff: only the changed lines plus two lines of context, numbered from `startLine`. */
|
|
8
|
+
export function DiffView({ before, after, startLine = 1 }: { before: string; after: string; startLine?: number }) {
|
|
9
|
+
const rows = useMemo(() => {
|
|
10
|
+
const ops = diffLines(before, after)
|
|
11
|
+
const keep = new Array<boolean>(ops.length).fill(false)
|
|
12
|
+
ops.forEach((op, index) => {
|
|
13
|
+
if (op.type === 'ctx') return
|
|
14
|
+
for (let k = Math.max(0, index - 2); k <= Math.min(ops.length - 1, index + 2); k += 1) keep[k] = true
|
|
15
|
+
})
|
|
16
|
+
let oldNo = startLine
|
|
17
|
+
let newNo = startLine
|
|
18
|
+
const out: Array<{ key: number; type: string; text: string; no: number | null; gap?: boolean }> = []
|
|
19
|
+
let skipped = false
|
|
20
|
+
ops.forEach((op, index) => {
|
|
21
|
+
const no = op.type === 'add' ? newNo : oldNo
|
|
22
|
+
if (op.type !== 'add') oldNo += 1
|
|
23
|
+
if (op.type !== 'del') newNo += 1
|
|
24
|
+
if (!keep[index]) {
|
|
25
|
+
skipped = true
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
if (skipped && out.length > 0) out.push({ key: -index, type: 'gap', text: '', no: null, gap: true })
|
|
29
|
+
skipped = false
|
|
30
|
+
out.push({ key: index, type: op.type, text: op.text, no })
|
|
31
|
+
})
|
|
32
|
+
return out
|
|
33
|
+
}, [before, after, startLine])
|
|
34
|
+
|
|
35
|
+
const shown = rows.slice(0, MAX_ROWS)
|
|
36
|
+
return (
|
|
37
|
+
<div className="diff-view thin-scroll max-h-64 overflow-auto rounded-lg border border-line bg-canvas/60 font-mono text-[11px] leading-[1.6]">
|
|
38
|
+
{shown.map((row, index) =>
|
|
39
|
+
row.gap ? (
|
|
40
|
+
<div key={row.key} className="px-2 text-center text-[10px] text-faint">⋯</div>
|
|
41
|
+
) : (
|
|
42
|
+
<div
|
|
43
|
+
key={row.key}
|
|
44
|
+
style={{ animationDelay: `${Math.min(index * 14, 260)}ms` }}
|
|
45
|
+
className={cn(
|
|
46
|
+
'diff-row flex min-w-max',
|
|
47
|
+
row.type === 'add' && 'bg-success/[0.11] text-ink',
|
|
48
|
+
row.type === 'del' && 'bg-danger/[0.10] text-ink',
|
|
49
|
+
row.type === 'ctx' && 'text-faint',
|
|
50
|
+
)}
|
|
51
|
+
>
|
|
52
|
+
<span className="w-9 shrink-0 select-none pr-2 text-right text-[10px] tabular-nums text-faint/70">{row.no}</span>
|
|
53
|
+
<span
|
|
54
|
+
className={cn(
|
|
55
|
+
'w-4 shrink-0 select-none text-center',
|
|
56
|
+
row.type === 'add' && 'text-success',
|
|
57
|
+
row.type === 'del' && 'text-danger',
|
|
58
|
+
)}
|
|
59
|
+
>
|
|
60
|
+
{row.type === 'add' ? '+' : row.type === 'del' ? '−' : ' '}
|
|
61
|
+
</span>
|
|
62
|
+
<span className="whitespace-pre pr-3">{row.text || ' '}</span>
|
|
63
|
+
</div>
|
|
64
|
+
),
|
|
65
|
+
)}
|
|
66
|
+
{rows.length > MAX_ROWS && (
|
|
67
|
+
<div className="px-3 py-1 text-[10px] text-faint">… {rows.length - MAX_ROWS} more rows</div>
|
|
68
|
+
)}
|
|
69
|
+
</div>
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function DiffBadge({
|
|
74
|
+
added,
|
|
75
|
+
removed,
|
|
76
|
+
line,
|
|
77
|
+
column,
|
|
78
|
+
className,
|
|
79
|
+
}: {
|
|
80
|
+
added: number
|
|
81
|
+
removed: number
|
|
82
|
+
line?: number
|
|
83
|
+
column?: number
|
|
84
|
+
className?: string
|
|
85
|
+
}) {
|
|
86
|
+
return (
|
|
87
|
+
<span className={cn('diff-badge inline-flex items-center gap-1.5 font-mono text-[11px] tabular-nums', className)}>
|
|
88
|
+
<span className="text-success">+{added}</span>
|
|
89
|
+
<span className="text-danger">−{removed}</span>
|
|
90
|
+
{line ? (
|
|
91
|
+
<span className="text-faint">
|
|
92
|
+
L{line}
|
|
93
|
+
{column ? `:${column}` : ''}
|
|
94
|
+
</span>
|
|
95
|
+
) : null}
|
|
96
|
+
</span>
|
|
97
|
+
)
|
|
98
|
+
}
|