Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 37 additions & 14 deletions src/main/agent-fix/claude-spawner.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,43 @@
import { spawn } from 'child_process'
import type { FixProgressEvent } from '../../shared/types'
import type { FixConfig, FixProgressEvent } from '../../shared/types'
import { truncate } from '../../shared/annotation-utils'
import { getFixConfig } from '../runtime/preferences'
import { parseStreamLine } from './stream-json-parser'

const DEFAULT_LOCAL_BASE_URL = 'http://localhost:1234'
const DEFAULT_LOCAL_AUTH_TOKEN = 'lmstudio'

export function buildClaudeInvocation(
prompt: string,
config: FixConfig,
): { args: string[]; env: Record<string, string> } {
const args = ['-p', prompt, '--output-format', 'stream-json', '--verbose']
const env: Record<string, string> = { NO_COLOR: '1' }

if (config.model === 'local') {
// Point Claude Code's CLI at an Anthropic-compatible local server
// (LM Studio ≥ 0.4.1 exposes /v1/messages). The CLI still handles
// skills, permissions, and stream-json on the client side.
const baseUrl = config.baseUrl?.trim() || DEFAULT_LOCAL_BASE_URL
const authToken = config.authToken?.trim() || DEFAULT_LOCAL_AUTH_TOKEN
env.ANTHROPIC_BASE_URL = baseUrl
env.ANTHROPIC_AUTH_TOKEN = authToken
const modelId = config.modelId?.trim()
if (modelId) {
env.ANTHROPIC_MODEL = modelId
args.push('--model', modelId)
}
} else if (config.model !== 'opus') {
args.push('--model', `claude-${config.model}-4-6`)
}

if (config.permissions === 'dangerously') {
args.push('--dangerously-skip-permissions')
}

return { args, env }
}

export interface FixResult {
summary: string
shouldResolve: boolean
Expand Down Expand Up @@ -38,25 +72,14 @@ export function invokeClaude(
const timeout = options.timeout ?? DEFAULT_TIMEOUT_MS

return new Promise<FixResult>((resolve, reject) => {
const config = getFixConfig()
const args = [
'-p', prompt,
'--output-format', 'stream-json',
'--verbose',
]
if (config.model !== 'opus') {
args.push('--model', `claude-${config.model}-4-6`)
}
if (config.permissions === 'dangerously') {
args.push('--dangerously-skip-permissions')
}
const { args, env } = buildClaudeInvocation(prompt, getFixConfig())
const child = spawn(
'claude',
args,
{
stdio: ['pipe', 'pipe', 'pipe'],
cwd: repoPath,
env: { ...process.env, NO_COLOR: '1' },
env: { ...process.env, ...env },
},
)

Expand Down
6 changes: 3 additions & 3 deletions src/main/ipc/register-right-details-panel-ipc.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { BrowserWindow, dialog, ipcMain } from 'electron'
import type { AnnotationCreateRequest, EdgeEnd, EdgeSide } from '../../shared/types'
import type { AnnotationCreateRequest, EdgeEnd, EdgeSide, FixConfigPatch } from '../../shared/types'
import {
getOriginBinding,
removeOriginBinding,
Expand Down Expand Up @@ -242,9 +242,9 @@ export function registerRightDetailsPanelIpc(): void {

ipcMain.on(
'right-details-panel-set-fix-config',
(_event, payload: { model?: string; permissions?: string } | undefined) => {
(_event, payload: FixConfigPatch | undefined) => {
if (!payload) return
setFixConfig(payload as { model?: 'opus' | 'sonnet' | 'haiku'; permissions?: 'dangerously' | 'default' })
setFixConfig(payload)
notifyDevtoolsPanelData()
},
)
Expand Down
11 changes: 9 additions & 2 deletions src/main/runtime/preferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type {
CursorMotionParams,
DevtoolsPanelTab,
FixConfig,
FixConfigPatch,
OnboardingState,
OriginBinding,
OriginBindings,
Expand Down Expand Up @@ -199,7 +200,13 @@ export function savePreferences(): void {
devtoolsWidth: uiDevtoolsWidth(),
devtoolsPanelTab: uiDevtoolsPanelTab(),
originBindings,
fixConfig: { model: fixConfig.model, permissions: fixConfig.permissions },
fixConfig: {
model: fixConfig.model,
permissions: fixConfig.permissions,
baseUrl: fixConfig.baseUrl,
modelId: fixConfig.modelId,
authToken: fixConfig.authToken,
},
})
}

Expand Down Expand Up @@ -228,7 +235,7 @@ export function getFixConfig(): FixConfig {
return fixConfig
}

export function setFixConfig(patch: { model?: FixConfig['model']; permissions?: FixConfig['permissions'] }): void {
export function setFixConfig(patch: FixConfigPatch): void {
fixConfig = { ...fixConfig, ...patch, configured: true }
savePreferences()
}
Expand Down
3 changes: 2 additions & 1 deletion src/preload/right-details-panel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type {
AnnotationCreateRequest,
DevtoolsPanelData,
DevtoolsPanelElectronAPI,
FixConfigPatch,
ThemeData,
} from '../shared/types'

Expand Down Expand Up @@ -39,7 +40,7 @@ const api: DevtoolsPanelElectronAPI = {
ipcRenderer.send('right-details-panel-pick-repo-for-origin', { origin }),
removeOriginBinding: (origin: string) =>
ipcRenderer.send('right-details-panel-remove-origin-binding', { origin }),
setFixConfig: (config: { model: string; permissions: string }) =>
setFixConfig: (config: FixConfigPatch) =>
ipcRenderer.send('right-details-panel-set-fix-config', config),
updateTextEntity: (id: string, patch: { color?: string }) =>
ipcRenderer.send('canvas-update-text-entity', { id, patch }),
Expand Down
49 changes: 48 additions & 1 deletion src/renderer/right-details-panel/components/DocumentPane.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,9 @@ function FixSettingsView({
}) {
const [model, setModel] = useState<FixModel>(fixConfig.model)
const [permissions, setPermissions] = useState<FixPermissions>(fixConfig.permissions)
const [baseUrl, setBaseUrl] = useState<string>(fixConfig.baseUrl ?? '')
const [modelId, setModelId] = useState<string>(fixConfig.modelId ?? '')
const [authToken, setAuthToken] = useState<string>(fixConfig.authToken ?? '')

const selectClass = `w-full rounded-md border px-2 py-1.5 text-[12px] ${
isDark
Expand All @@ -237,7 +240,11 @@ function FixSettingsView({
}`

const handleConfirm = () => {
rightDetailsPanelApi.setFixConfig({ model, permissions })
rightDetailsPanelApi.setFixConfig(
model === 'local'
? { model, permissions, baseUrl, modelId, authToken }
: { model, permissions },
)
onDone()
}

Expand All @@ -259,9 +266,49 @@ function FixSettingsView({
<option value="opus">Opus</option>
<option value="sonnet">Sonnet</option>
<option value="haiku">Haiku</option>
<option value="local">Local (LM Studio)</option>
</select>
</div>

{model === 'local' ? (
<div className="space-y-2">
<div>
<label className="mb-1 block text-[11px] font-medium">Base URL</label>
<input
type="text"
value={baseUrl}
onChange={(e) => setBaseUrl(e.target.value)}
placeholder="http://localhost:1234"
className={selectClass}
/>
</div>
<div>
<label className="mb-1 block text-[11px] font-medium">Model id</label>
<input
type="text"
value={modelId}
onChange={(e) => setModelId(e.target.value)}
placeholder="qwen/qwen3-coder-30b"
className={selectClass}
/>
</div>
<div>
<label className="mb-1 block text-[11px] font-medium">Auth token</label>
<input
type="text"
value={authToken}
onChange={(e) => setAuthToken(e.target.value)}
placeholder="lmstudio"
className={selectClass}
/>
</div>
<p className={`text-[10px] leading-snug ${muted}`}>
Claude Code will POST to {'{baseUrl}'}/v1/messages. Requires LM Studio 0.4.1+ with a
loaded model and the server started.
</p>
</div>
) : null}

<div>
<label className="mb-1 block text-[11px] font-medium">Permissions</label>
<select
Expand Down
17 changes: 15 additions & 2 deletions src/shared/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1590,7 +1590,7 @@ export interface DevtoolsPanelElectronAPI {
setAutoFix: (origin: string, enabled: boolean) => void
pickRepoForOrigin: (origin: string) => void
removeOriginBinding: (origin: string) => void
setFixConfig: (config: { model: FixModel; permissions: FixPermissions }) => void
setFixConfig: (config: FixConfigPatch) => void
updateTextEntity: (id: string, patch: { color?: string }) => void
duplicateTextEntity: (id: string) => void
deleteTextEntity: (id: string) => void
Expand Down Expand Up @@ -1735,13 +1735,26 @@ export type OriginBindings = Record<string, OriginBinding>

// --- Fix config (model + permissions for the Claude subprocess) ---

export type FixModel = 'opus' | 'sonnet' | 'haiku'
export type FixModel = 'opus' | 'sonnet' | 'haiku' | 'local'
export type FixPermissions = 'dangerously' | 'default'

export interface FixConfig {
model: FixModel
permissions: FixPermissions
configured: boolean
// Used only when model === 'local'. Points Claude Code at an
// Anthropic-compatible endpoint such as LM Studio's /v1/messages.
baseUrl?: string
modelId?: string
authToken?: string
}

export interface FixConfigPatch {
model?: FixModel
permissions?: FixPermissions
baseUrl?: string
modelId?: string
authToken?: string
}

// --- Fix progress (live stream of `claude -p` events per annotation) ---
Expand Down
60 changes: 59 additions & 1 deletion tests/unit/claude-spawner.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
import { parseOutput } from '../../src/main/agent-fix/claude-spawner'
import { buildClaudeInvocation, parseOutput } from '../../src/main/agent-fix/claude-spawner'
import type { FixConfig } from '../../src/shared/types'

const base: FixConfig = { model: 'opus', permissions: 'default', configured: true }

describe('parseOutput', () => {
it('treats <<RESOLVE>> on its own line as resolving, with prior line as summary', () => {
Expand Down Expand Up @@ -50,3 +53,58 @@ describe('parseOutput', () => {
expect(result.summary.endsWith('…')).toBe(true)
})
})

describe('buildClaudeInvocation', () => {
it('omits --model for Opus and does not set any Anthropic env', () => {
const { args, env } = buildClaudeInvocation('hi', base)
expect(args).toEqual(['-p', 'hi', '--output-format', 'stream-json', '--verbose'])
expect(env).toEqual({ NO_COLOR: '1' })
})

it('adds --model flag for Sonnet and Haiku', () => {
expect(buildClaudeInvocation('hi', { ...base, model: 'sonnet' }).args).toContain('claude-sonnet-4-6')
expect(buildClaudeInvocation('hi', { ...base, model: 'haiku' }).args).toContain('claude-haiku-4-6')
})

it('appends --dangerously-skip-permissions when permissions === dangerously', () => {
const { args } = buildClaudeInvocation('hi', { ...base, permissions: 'dangerously' })
expect(args).toContain('--dangerously-skip-permissions')
})

it('routes to a local Anthropic-compatible endpoint when model === local', () => {
const { args, env } = buildClaudeInvocation('hi', {
...base,
model: 'local',
baseUrl: 'http://localhost:1234',
modelId: 'qwen/qwen3-coder-30b',
authToken: 'secret',
})
expect(env.ANTHROPIC_BASE_URL).toBe('http://localhost:1234')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('secret')
expect(env.ANTHROPIC_MODEL).toBe('qwen/qwen3-coder-30b')
expect(args).toContain('--model')
expect(args).toContain('qwen/qwen3-coder-30b')
expect(args).not.toContain('claude-local-4-6')
})

it('falls back to LM Studio defaults when baseUrl / authToken are missing', () => {
const { env, args } = buildClaudeInvocation('hi', { ...base, model: 'local' })
expect(env.ANTHROPIC_BASE_URL).toBe('http://localhost:1234')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('lmstudio')
expect(env.ANTHROPIC_MODEL).toBeUndefined()
expect(args).not.toContain('--model')
})

it('trims whitespace in local endpoint config', () => {
const { env } = buildClaudeInvocation('hi', {
...base,
model: 'local',
baseUrl: ' http://host:9000 ',
authToken: ' token ',
modelId: ' m ',
})
expect(env.ANTHROPIC_BASE_URL).toBe('http://host:9000')
expect(env.ANTHROPIC_AUTH_TOKEN).toBe('token')
expect(env.ANTHROPIC_MODEL).toBe('m')
})
})