refactor(code-cli): split renderer — rebuild Code CLI page, drop OpenClaw page

Renderer half of the code-cli refactor (stacked on the main-process PR).

- Rebuild Code CLI page with sidebar, config cards, provider cards, config edit panel, version status
- Multi-config per CLI tool UI (named configs)
- Drop the standalone OpenClaw page (OpenClaw is now managed under Code CLI); remove OpenClawPage, UpdateButton, route, sidebar entry
- Update useCodeCli hook + store + i18n for the new config model

Part 2 of 2. Depends on the main-process PR; base will move to main after it merges.

Note: 11 web typecheck errors remain from rebasing the code-cli renderer onto latest main's UI/preference types — to be resolved before flipping this PR out of draft.
Signed-off-by: Pleasurecruise <3196812536@qq.com>
This commit is contained in:
Pleasurecruise
2026-06-26 13:56:16 +08:00
parent 87278a77c9
commit c65367a173
23 changed files with 2015 additions and 2240 deletions

View File

@@ -14,8 +14,6 @@ import {
Settings
} from 'lucide-react'
import { OpenClawSidebarIcon } from '../Icons/SvgIcon'
export type IconComponent = React.FC<{ size?: number; strokeWidth?: number; className?: string }>
// ─── Route → Icon mapping ─────────────────────────────────────────────────────
@@ -31,7 +29,6 @@ export const ROUTE_ICONS: Record<string, IconComponent> = {
'/app/files': Folder,
'/app/code': Code,
'/app/notes': NotepadText,
'/app/openclaw': OpenClawSidebarIcon,
'/settings': Settings
}

View File

@@ -1,280 +1,221 @@
import { CodeCli, TerminalApp } from '@shared/types/codeCli'
import type { CodeCliConfigs } from '@shared/data/preference/preferenceTypes'
import { codeCLI } from '@shared/types/codeCli'
import { mockUsePreference, MockUsePreferenceUtils } from '@test-mocks/renderer/usePreference'
import { act, renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { useCodeCli } from '../useCodeCli'
/**
* Helper: set up the unified mock to return a specific overrides value
* and capture calls to the setter.
*/
function setupOverridesMock(overrides: Record<string, any>) {
const mockSetOverrides = vi.fn().mockResolvedValue(undefined)
/** Set up the preference mock for `feature.code_cli.configs` and capture setter calls. */
function setupConfigsMock(configs: CodeCliConfigs) {
const mockSetConfigs = vi.fn().mockResolvedValue(undefined)
mockUsePreference.mockImplementation((key: string) => {
if (key === 'feature.code_cli.overrides') {
return [overrides, mockSetOverrides]
if (key === 'feature.code_cli.configs') {
return [configs, mockSetConfigs]
}
return [null, vi.fn().mockResolvedValue(undefined)]
return [{} as CodeCliConfigs, vi.fn().mockResolvedValue(undefined)]
})
return mockSetOverrides
return mockSetConfigs
}
/** Set updater-style setter that receives a function (mirrors real usePreference updater). */
function setupUpdaterMock(configs: CodeCliConfigs) {
let current = configs
const mockSetConfigs = vi.fn((updater: (prev: CodeCliConfigs) => CodeCliConfigs) => {
current = updater(current)
return Promise.resolve()
})
mockUsePreference.mockImplementation((key: string) => {
if (key === 'feature.code_cli.configs') {
return [current, mockSetConfigs]
}
return [{} as CodeCliConfigs, vi.fn().mockResolvedValue(undefined)]
})
return mockSetConfigs
}
const cfg = (
overrides: Partial<{ id: string; name: string; providerId: string; modelId: string; directory: string }> = {}
) => ({
id: overrides.id ?? 'cfg1',
name: overrides.name ?? 'Work Claude',
providerId: overrides.providerId ?? 'anthropic',
modelId: overrides.modelId ?? 'anthropic::claude-4',
createdAt: 1,
sortIndex: 0,
...(overrides.directory ? { directory: overrides.directory } : {})
})
const state = (providers: Record<string, ReturnType<typeof cfg>>, current: string | null) => ({
providers,
current
})
describe('useCodeCli', () => {
beforeEach(() => {
MockUsePreferenceUtils.resetMocks()
})
describe('selectedCliTool', () => {
it('should return default tool when no tool is enabled', () => {
setupOverridesMock({})
const { result } = renderHook(() => useCodeCli())
expect(result.current.selectedCliTool).toBe(CodeCli.QWEN_CODE)
})
it('should return the enabled tool', () => {
setupOverridesMock({ 'claude-code': { enabled: true } })
it('should default to claude-code', () => {
setupConfigsMock({} as CodeCliConfigs)
const { result } = renderHook(() => useCodeCli())
expect(result.current.selectedCliTool).toBe(CodeCli.CLAUDE_CODE)
})
})
describe('currentConfig', () => {
it('should return per-tool modelId', () => {
setupOverridesMock({ 'claude-code': { enabled: true, modelId: 'anthropic::claude-3-opus' } })
it('selectTool should switch the selected tool (navigation state)', () => {
setupConfigsMock({
'openai-codex': state({ cfg1: cfg() }, 'cfg1')
} as CodeCliConfigs)
const { result } = renderHook(() => useCodeCli())
expect(result.current.selectedModel).toBe('anthropic::claude-3-opus')
})
it('should return default terminal when none set', () => {
setupOverridesMock({})
const { result } = renderHook(() => useCodeCli())
expect(result.current.selectedTerminal).toBe(TerminalApp.SYSTEM_DEFAULT)
})
})
describe('setCliTool', () => {
it('should disable current tool and enable new tool', async () => {
const mockSetter = setupOverridesMock({ 'qwen-code': { enabled: true } })
const { result } = renderHook(() => useCodeCli())
await act(async () => {
await result.current.setCliTool(CodeCli.CLAUDE_CODE)
act(() => {
result.current.selectTool(codeCLI.openaiCodex)
})
expect(mockSetter).toHaveBeenCalledWith(
expect.objectContaining({
'qwen-code': expect.objectContaining({ enabled: false }),
'claude-code': expect.objectContaining({ enabled: true })
})
)
expect(result.current.selectedCliTool).toBe(codeCLI.openaiCodex)
})
})
describe('setModel', () => {
it('should update modelId for current tool', async () => {
const mockSetter = setupOverridesMock({ 'qwen-code': { enabled: true } })
describe('currentConfig / orderedList', () => {
it('should expose the current config', () => {
setupConfigsMock({
'claude-code': state({ cfg1: cfg() }, 'cfg1')
} as CodeCliConfigs)
const { result } = renderHook(() => useCodeCli())
expect(result.current.currentConfig?.id).toBe('cfg1')
expect(result.current.selectedModel).toBe('anthropic::claude-4')
})
await act(async () => {
await result.current.setModel('openai::gpt-4')
})
expect(mockSetter).toHaveBeenCalledWith(
expect.objectContaining({
'qwen-code': expect.objectContaining({ modelId: 'openai::gpt-4' })
})
)
it('should expose an ordered config list', () => {
setupConfigsMock({
'claude-code': state(
{
a: cfg({ id: 'a', name: 'A', sortIndex: undefined as unknown as number }),
b: cfg({ id: 'b', name: 'B' })
},
'b'
)
} as CodeCliConfigs)
const { result } = renderHook(() => useCodeCli())
expect(result.current.orderedList.map((c) => c.id)).toEqual(['a', 'b'])
})
})
describe('canLaunch', () => {
it('should be true when tool, directory, and model are set', () => {
setupOverridesMock({
'qwen-code': { enabled: true, modelId: 'openai::gpt-4', currentDirectory: '/tmp/project' }
})
it('should be true when the current config has model and directory', () => {
setupConfigsMock({
'claude-code': state({ cfg1: cfg({ directory: '/tmp/project' }) }, 'cfg1')
} as CodeCliConfigs)
const { result } = renderHook(() => useCodeCli())
expect(result.current.canLaunch).toBe(true)
})
it('should be true for github-copilot-cli without model', () => {
setupOverridesMock({
'github-copilot-cli': { enabled: true, currentDirectory: '/tmp/project' }
})
const { result } = renderHook(() => useCodeCli())
expect(result.current.canLaunch).toBe(true)
})
it('should be true for qoder-cli without model', () => {
setupOverridesMock({
'qoder-cli': { enabled: true, currentDirectory: '/tmp/project' }
})
const { result } = renderHook(() => useCodeCli())
expect(result.current.canLaunch).toBe(true)
})
it('should be false when no directory is set', () => {
setupOverridesMock({
'qwen-code': { enabled: true, modelId: 'openai::gpt-4' }
})
it('should be false when the current config has no directory', () => {
setupConfigsMock({
'claude-code': state({ cfg1: cfg() }, 'cfg1')
} as CodeCliConfigs)
const { result } = renderHook(() => useCodeCli())
expect(result.current.canLaunch).toBe(false)
})
it('should be false when no model is set for non-copilot tool', () => {
setupOverridesMock({
'qwen-code': { enabled: true, currentDirectory: '/tmp/project' }
})
it('should be false when there is no current config', () => {
setupConfigsMock({
'claude-code': state({ cfg1: cfg() }, null)
} as CodeCliConfigs)
const { result } = renderHook(() => useCodeCli())
expect(result.current.canLaunch).toBe(false)
})
})
describe('setCurrentDir', () => {
it('should set directory and add to directories list', async () => {
const mockSetter = setupOverridesMock({ 'qwen-code': { enabled: true } })
describe('addConfig', () => {
it('should add a new named config with a generated id', async () => {
const mockSetter = setupUpdaterMock({} as CodeCliConfigs)
const { result } = renderHook(() => useCodeCli())
let newId = ''
await act(async () => {
await result.current.setCurrentDir('/new/project')
})
expect(mockSetter).toHaveBeenCalledWith(
expect.objectContaining({
'qwen-code': expect.objectContaining({
currentDirectory: '/new/project',
directories: ['/new/project']
})
newId = await result.current.addConfig(codeCLI.claudeCode, {
name: 'New',
providerId: 'anthropic',
modelId: 'anthropic::claude-4'
})
)
})
it('should move existing directory to front of list', async () => {
const mockSetter = setupOverridesMock({
'qwen-code': { enabled: true, directories: ['/a', '/b', '/c'] }
})
const { result } = renderHook(() => useCodeCli())
await act(async () => {
await result.current.setCurrentDir('/c')
})
const calledValue = mockSetter.mock.calls[0][0]
expect(calledValue['qwen-code'].directories).toEqual(['/c', '/a', '/b'])
})
it('should limit directories list to 10 entries', async () => {
const dirs = Array.from({ length: 10 }, (_, i) => `/dir${i}`)
const mockSetter = setupOverridesMock({
'qwen-code': { enabled: true, directories: dirs }
})
const { result } = renderHook(() => useCodeCli())
await act(async () => {
await result.current.setCurrentDir('/new-dir')
})
const calledValue = mockSetter.mock.calls[0][0]
expect(calledValue['qwen-code'].directories).toHaveLength(10)
expect(calledValue['qwen-code'].directories[0]).toBe('/new-dir')
})
it('should not modify directories when directory is empty', async () => {
const mockSetter = setupOverridesMock({
'qwen-code': { enabled: true, directories: ['/a', '/b'] }
})
const { result } = renderHook(() => useCodeCli())
await act(async () => {
await result.current.setCurrentDir('')
})
const calledValue = mockSetter.mock.calls[0][0]
expect(calledValue['qwen-code'].directories).toEqual(['/a', '/b'])
expect(calledValue['qwen-code'].currentDirectory).toBe('')
expect(newId).toBeTruthy()
expect(mockSetter).toHaveBeenCalled()
})
})
describe('removeDir', () => {
it('should remove directory from the list', async () => {
const mockSetter = setupOverridesMock({
'qwen-code': { enabled: true, directories: ['/a', '/b', '/c'] }
})
describe('updateConfig', () => {
it('should patch an existing config', async () => {
const mockSetter = setupUpdaterMock({
'claude-code': state({ cfg1: cfg() }, 'cfg1')
} as CodeCliConfigs)
const { result } = renderHook(() => useCodeCli())
await act(async () => {
await result.current.removeDir('/b')
await result.current.updateConfig(codeCLI.claudeCode, 'cfg1', { name: 'Renamed' })
})
const calledValue = mockSetter.mock.calls[0][0]
expect(calledValue['qwen-code'].directories).toEqual(['/a', '/c'])
expect(mockSetter).toHaveBeenCalled()
})
it('should reset currentDirectory when removing the current directory', async () => {
const mockSetter = setupOverridesMock({
'qwen-code': { enabled: true, currentDirectory: '/a', directories: ['/a', '/b'] }
})
it('should be a no-op when the config id does not exist', async () => {
const mockSetter = setupUpdaterMock({
'claude-code': state({ cfg1: cfg() }, 'cfg1')
} as CodeCliConfigs)
const { result } = renderHook(() => useCodeCli())
await act(async () => {
await result.current.removeDir('/a')
await result.current.updateConfig(codeCLI.claudeCode, 'missing', { name: 'X' })
})
const calledValue = mockSetter.mock.calls[0][0]
expect(calledValue['qwen-code'].currentDirectory).toBe('')
expect(calledValue['qwen-code'].directories).toEqual(['/b'])
})
it('should not reset currentDirectory when removing a different directory', async () => {
const mockSetter = setupOverridesMock({
'qwen-code': { enabled: true, currentDirectory: '/a', directories: ['/a', '/b'] }
})
const { result } = renderHook(() => useCodeCli())
await act(async () => {
await result.current.removeDir('/b')
})
const calledValue = mockSetter.mock.calls[0][0]
expect(calledValue['qwen-code'].currentDirectory).toBe('/a')
expect(calledValue['qwen-code'].directories).toEqual(['/a'])
// Setter still called (it patches), but the providers map is unchanged by the reducer
expect(mockSetter).toHaveBeenCalled()
})
})
describe('resetSettings', () => {
it('should reset overrides to empty object', async () => {
const mockSetter = setupOverridesMock({
'qwen-code': { enabled: true, modelId: 'some-model' }
})
describe('deleteConfig', () => {
it('should remove the config and clear current if it was active', async () => {
const mockSetter = setupUpdaterMock({
'claude-code': state({ cfg1: cfg() }, 'cfg1')
} as CodeCliConfigs)
const { result } = renderHook(() => useCodeCli())
await act(async () => {
await result.current.resetSettings()
await result.current.deleteConfig(codeCLI.claudeCode, 'cfg1')
})
expect(mockSetter).toHaveBeenCalledWith({})
expect(mockSetter).toHaveBeenCalled()
})
})
describe('clearDirs', () => {
it('should clear directories and currentDirectory', async () => {
const mockSetter = setupOverridesMock({
'qwen-code': { enabled: true, currentDirectory: '/a', directories: ['/a', '/b'] }
})
describe('setCurrentConfig', () => {
it('should set the tool current pointer', async () => {
const mockSetter = setupUpdaterMock({
'claude-code': state({ cfg1: cfg(), cfg2: cfg({ id: 'cfg2', name: 'B' }) }, 'cfg1')
} as CodeCliConfigs)
const { result } = renderHook(() => useCodeCli())
await act(async () => {
await result.current.clearDirs()
await result.current.setCurrentConfig(codeCLI.claudeCode, 'cfg2')
})
expect(mockSetter).toHaveBeenCalledWith(
expect.objectContaining({
'qwen-code': expect.objectContaining({
directories: [],
currentDirectory: ''
})
})
)
expect(mockSetter).toHaveBeenCalled()
})
})
describe('setDirectory', () => {
it('should set a config directory and prepend to the tool MRU list', async () => {
const mockSetter = setupUpdaterMock({
'claude-code': state({ cfg1: cfg() }, 'cfg1')
} as CodeCliConfigs)
const { result } = renderHook(() => useCodeCli())
await act(async () => {
await result.current.setDirectory('cfg1', '/new/project')
})
expect(mockSetter).toHaveBeenCalled()
})
})
})

View File

@@ -1,108 +1,201 @@
import { usePreference } from '@data/hooks/usePreference'
import { loggerService } from '@logger'
import type { CodeCliId, CodeCliOverride, CodeCliOverrides } from '@shared/data/preference/preferenceTypes'
import { CODE_CLI_PRESET_MAP } from '@shared/data/presets/codeCli'
import { CodeCli } from '@shared/types/codeCli'
import { useCallback, useMemo } from 'react'
import type {
CliNamedConfig,
CodeCliConfigs,
CodeCliId,
CodeCliToolState
} from '@shared/data/preference/preferenceTypes'
import { codeCLI } from '@shared/types/codeCli'
import { useCallback, useMemo, useState } from 'react'
const logger = loggerService.withContext('useCodeCli')
const DEFAULT_TOOL = CodeCli.QWEN_CODE as CodeCliId
const PREFERENCE_KEY = 'feature.code_cli.configs'
const DEFAULT_TOOL = codeCLI.claudeCode as CodeCliId
function getEffectiveToolConfig(toolId: CodeCliId, overrides: CodeCliOverrides): Required<CodeCliOverride> {
const preset = CODE_CLI_PRESET_MAP[toolId]
const override = overrides[toolId] ?? {}
return {
enabled: override.enabled ?? preset.enabled,
modelId: override.modelId ?? preset.modelId,
envVars: override.envVars ?? preset.envVars,
terminal: override.terminal ?? preset.terminal,
currentDirectory: override.currentDirectory ?? preset.currentDirectory,
directories: override.directories ?? [...preset.directories]
}
const EMPTY_TOOL_STATE: CodeCliToolState = { providers: {}, current: null }
function getToolState(toolId: CodeCliId, configs: CodeCliConfigs): CodeCliToolState {
return configs[toolId] ?? EMPTY_TOOL_STATE
}
/** Generate a short unique client id (timestamp + random). */
function generateConfigId(): string {
return `cfg_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`
}
/** Ordered list of configs for a tool (sorted by sortIndex then insertion). */
function orderedConfigs(state: CodeCliToolState): CliNamedConfig[] {
const entries = Object.values(state.providers)
return entries.sort((a, b) => {
const ai = a.sortIndex ?? 0
const bi = b.sortIndex ?? 0
if (ai !== bi) return ai - bi
return (a.createdAt ?? 0) - (b.createdAt ?? 0)
})
}
export const useCodeCli = () => {
const [overrides, setOverrides] = usePreference('feature.code_cli.overrides')
const [configs, setConfigs] = usePreference(PREFERENCE_KEY)
const selectedCliTool = useMemo(() => {
for (const [toolId, override] of Object.entries(overrides)) {
if (override?.enabled) {
return toolId as CodeCli
}
}
return DEFAULT_TOOL as CodeCli
}, [overrides])
const [selectedCliTool, setSelectedCliTool] = useState<codeCLI>(DEFAULT_TOOL)
const selectTool = useCallback((tool: codeCLI) => {
setSelectedCliTool(tool)
}, [])
const currentToolState = useMemo(() => getToolState(selectedCliTool, configs), [selectedCliTool, configs])
const orderedList = useMemo(() => orderedConfigs(currentToolState), [currentToolState])
const currentConfig = useMemo(
() => getEffectiveToolConfig(selectedCliTool as CodeCliId, overrides),
[selectedCliTool, overrides]
() => (currentToolState.current ? (currentToolState.providers[currentToolState.current] ?? null) : null),
[currentToolState]
)
const selectedModel = currentConfig.modelId
const selectedTerminal = currentConfig.terminal
const environmentVariables = currentConfig.envVars
const directories = currentConfig.directories
const currentDirectory = currentConfig.currentDirectory
const selectedModel = currentConfig?.modelId ?? null
const selectedTerminal = currentToolState.terminal
const directories = currentToolState.directories ?? []
const canLaunch = Boolean(
selectedCliTool &&
currentDirectory &&
(selectedCliTool === CodeCli.GITHUB_COPILOT_CLI || selectedCliTool === CodeCli.QODER_CLI || selectedModel)
)
const canLaunch = Boolean(currentConfig?.modelId && currentConfig?.directory)
const updateCurrentTool = useCallback(
async (patch: Partial<CodeCliOverride>) => {
const toolId = selectedCliTool as CodeCliId
const existing = overrides[toolId] ?? {}
await setOverrides({
...overrides,
[toolId]: { ...existing, ...patch }
/** Patch a single tool's state. */
const patchToolState = useCallback(
async (toolId: CodeCliId, patch: (prev: CodeCliToolState) => CodeCliToolState) => {
await setConfigs((prevConfigs: CodeCliConfigs) => {
const prev = getToolState(toolId, prevConfigs)
return { ...prevConfigs, [toolId]: patch(prev) }
})
},
[overrides, setOverrides, selectedCliTool]
[setConfigs]
)
const setCliTool = useCallback(
async (tool: CodeCli) => {
const newOverrides = { ...overrides }
const currentId = selectedCliTool as CodeCliId
if (newOverrides[currentId]) {
newOverrides[currentId] = { ...newOverrides[currentId], enabled: false }
/** Add a new named config for a tool. Returns the new config id. */
const addConfig = useCallback(
async (
toolId: CodeCliId,
partial: Pick<CliNamedConfig, 'name' | 'providerId' | 'modelId'> & Partial<CliNamedConfig>
): Promise<string> => {
const id = generateConfigId()
const now = Date.now()
const orderLen = orderedConfigs(getToolState(toolId, configs)).length
const config: CliNamedConfig = {
id,
name: partial.name,
providerId: partial.providerId,
modelId: partial.modelId,
createdAt: now,
sortIndex: orderLen,
...(partial.advanced ? { advanced: partial.advanced } : {}),
...(partial.directory ? { directory: partial.directory } : {}),
...(partial.notes ? { notes: partial.notes } : {}),
...(partial.icon ? { icon: partial.icon } : {}),
...(partial.iconColor ? { iconColor: partial.iconColor } : {})
}
const newId = tool as CodeCliId
newOverrides[newId] = { ...newOverrides[newId], enabled: true }
await setOverrides(newOverrides)
await patchToolState(toolId, (prev) => ({
...prev,
providers: { ...prev.providers, [id]: config }
}))
logger.info('Added CLI config', { toolId, configId: id })
return id
},
[overrides, setOverrides, selectedCliTool]
[configs, patchToolState]
)
const setModel = useCallback(
async (modelId: string | null) => {
await updateCurrentTool({ modelId })
/** Update an existing named config (by id) for a tool. */
const updateConfig = useCallback(
async (toolId: CodeCliId, configId: string, patch: Partial<CliNamedConfig>) => {
await patchToolState(toolId, (prev) => {
const existing = prev.providers[configId]
if (!existing) return prev
return { ...prev, providers: { ...prev.providers, [configId]: { ...existing, ...patch } } }
})
},
[updateCurrentTool]
[patchToolState]
)
/** Duplicate an existing config under a new id. */
const duplicateConfig = useCallback(
async (toolId: CodeCliId, configId: string): Promise<string | null> => {
const existing = getToolState(toolId, configs).providers[configId]
if (!existing) return null
const id = generateConfigId()
const orderLen = orderedConfigs(getToolState(toolId, configs)).length
const copy: CliNamedConfig = {
...existing,
id,
name: `${existing.name} copy`,
createdAt: Date.now(),
sortIndex: orderLen
}
await patchToolState(toolId, (prev) => ({
...prev,
providers: { ...prev.providers, [id]: copy }
}))
logger.info('Duplicated CLI config', { toolId, from: configId, to: id })
return id
},
[configs, patchToolState]
)
/** Delete a named config; clears `current` if it was active. */
const deleteConfig = useCallback(
async (toolId: CodeCliId, configId: string) => {
await patchToolState(toolId, (prev) => {
const nextProviders = { ...prev.providers }
delete nextProviders[configId]
return {
...prev,
providers: nextProviders,
current: prev.current === configId ? null : prev.current
}
})
},
[patchToolState]
)
/** Set the tool's active config. */
const setCurrentConfig = useCallback(
async (toolId: CodeCliId, configId: string) => {
await patchToolState(toolId, (prev) => ({ ...prev, current: configId }))
},
[patchToolState]
)
/** Reorder configs by id list (drag-to-reorder). */
const reorderConfigs = useCallback(
async (toolId: CodeCliId, orderedIds: string[]) => {
await patchToolState(toolId, (prev) => {
const nextProviders: Record<string, CliNamedConfig> = {}
orderedIds.forEach((id, index) => {
const existing = prev.providers[id]
if (existing) nextProviders[id] = { ...existing, sortIndex: index }
})
const remainder = orderedConfigs(prev)
.filter((c) => !orderedIds.includes(c.id))
.map((c, i) => ({ ...c, sortIndex: orderedIds.length + i }))
for (const c of remainder) nextProviders[c.id] = c
return { ...prev, providers: nextProviders }
})
},
[patchToolState]
)
/** Set the tool-level terminal (shared across the tool's configs). */
const setTerminal = useCallback(
async (terminal: string) => {
await updateCurrentTool({ terminal })
await patchToolState(selectedCliTool as CodeCliId, (prev) => ({ ...prev, terminal }))
},
[updateCurrentTool]
[patchToolState, selectedCliTool]
)
const setEnvVars = useCallback(
async (envVars: string) => {
await updateCurrentTool({ envVars })
},
[updateCurrentTool]
)
const setCurrentDir = useCallback(
async (directory: string) => {
/** Set a config's working directory and refresh the tool-level MRU list. */
const setDirectory = useCallback(
async (configId: string, directory: string) => {
const toolId = selectedCliTool as CodeCliId
const existing = overrides[toolId] ?? {}
const currentDirs = existing.directories ?? []
const state = getToolState(toolId, configs)
const currentDirs = state.directories ?? []
let newDirs: string[]
if (directory && !currentDirs.includes(directory)) {
newDirs = [directory, ...currentDirs].slice(0, 10)
@@ -111,70 +204,68 @@ export const useCodeCli = () => {
} else {
newDirs = currentDirs
}
await setOverrides({
...overrides,
[toolId]: { ...existing, currentDirectory: directory, directories: newDirs }
})
await patchToolState(toolId, (prev) => ({
...prev,
directories: newDirs,
providers: prev.providers[configId]
? { ...prev.providers, [configId]: { ...prev.providers[configId], directory } }
: prev.providers
}))
},
[overrides, setOverrides, selectedCliTool]
[configs, patchToolState, selectedCliTool]
)
/** Pick a folder via native dialog and assign it to the given config. */
const selectFolder = useCallback(
async (configId: string): Promise<string | null> => {
try {
const folderPath = await window.api.file.selectFolder()
if (folderPath) {
await setDirectory(configId, folderPath)
return folderPath
}
return null
} catch (error) {
logger.error('Failed to select folder:', error as Error)
throw error
}
},
[setDirectory]
)
const removeDir = useCallback(
async (directory: string) => {
const toolId = selectedCliTool as CodeCliId
const existing = overrides[toolId] ?? {}
const currentDirs = existing.directories ?? []
const newDirs = currentDirs.filter((d) => d !== directory)
const patch: Partial<CodeCliOverride> = { directories: newDirs }
if (existing.currentDirectory === directory) {
patch.currentDirectory = ''
}
await setOverrides({
...overrides,
[toolId]: { ...existing, ...patch }
await patchToolState(selectedCliTool as CodeCliId, (prev) => {
const currentDirs = prev.directories ?? []
const newDirs = currentDirs.filter((d) => d !== directory)
return { ...prev, directories: newDirs }
})
},
[overrides, setOverrides, selectedCliTool]
[patchToolState, selectedCliTool]
)
const clearDirs = useCallback(async () => {
await updateCurrentTool({ directories: [], currentDirectory: '' })
}, [updateCurrentTool])
const resetSettings = useCallback(async () => {
await setOverrides({})
}, [setOverrides])
const selectFolder = useCallback(async () => {
try {
const folderPath = await window.api.file.selectFolder()
if (folderPath) {
await setCurrentDir(folderPath)
return folderPath
}
return null
} catch (error) {
logger.error('Failed to select folder:', error as Error)
throw error
}
}, [setCurrentDir])
return {
selectedCliTool,
configs,
currentToolState,
orderedList,
currentConfig,
selectedModel,
selectedTerminal,
environmentVariables,
directories,
currentDirectory,
canLaunch,
setCliTool,
setModel,
// config CRUD
addConfig,
updateConfig,
duplicateConfig,
deleteConfig,
setCurrentConfig,
reorderConfigs,
// tool-level
selectTool,
setTerminal,
setEnvVars,
setCurrentDir,
removeDir,
clearDirs,
resetSettings,
selectFolder
setDirectory,
selectFolder,
removeDir
}
}

View File

@@ -165,7 +165,6 @@ const titleKeyMap = {
paintings: 'title.paintings',
settings: 'title.settings',
translate: 'title.translate',
openclaw: 'openclaw.title',
agents: 'agent.sidebar_title'
} as const
@@ -193,8 +192,7 @@ const sidebarIconKeyMap = {
knowledge: 'knowledge.title',
files: 'files.title',
code_tools: 'code.title',
notes: 'notes.title',
openclaw: 'openclaw.title'
notes: 'notes.title'
} as const
export const getSidebarIconLabelKey = (key: string): string => {
@@ -214,8 +212,7 @@ const sidebarFavoriteKeyMap = {
knowledge: 'knowledge.title',
files: 'files.title',
code_tools: 'code.title',
notes: 'notes.title',
openclaw: 'openclaw.title'
notes: 'notes.title'
} as const
export const getSidebarFavoriteLabelKey = (key: string): string => {
return getLabelKey(sidebarFavoriteKeyMap, key)

View File

@@ -1933,23 +1933,86 @@
}
},
"code": {
"add_config": "Add",
"add_provider_hint": "Add provider in Settings → Model Service",
"adv": {
"claude": {
"auto_compact_window": "Auto Compact Window",
"effort_level": "Reasoning Effort",
"enable_tool_search": "Enable Tool Search",
"include_co_authored_by": "Include Co-authored-by",
"max_output_tokens": "Max Output Tokens",
"skip_web_fetch_preflight": "Skip Web Fetch Preflight",
"timeout_ms": "API Timeout (ms)",
"disable_nonessential_traffic": "Disable Nonessential Traffic",
"disable_experimental_betas": "Disable Experimental Betas"
},
"codex": {
"auto_compact_token_limit": "Auto Compact Token Limit",
"context_window": "Context Window",
"disable_response_storage": "Disable Response Storage",
"personality": "Personality",
"reasoning_effort": "Reasoning Effort",
"review_model": "Review Model",
"verbosity": "Verbosity"
},
"effort": {
"high": "High",
"low": "Low",
"medium": "Medium"
},
"hermes": {
"context_length": "Context Length",
"max_tokens": "Max Tokens"
},
"openclaw": {
"context_window": "Context Window",
"max_tokens": "Max Tokens",
"reasoning": "Enable Reasoning"
},
"opencode": {
"budget_tokens": "Reasoning Budget Tokens",
"context_limit": "Context Limit",
"output_limit": "Output Limit"
},
"select_placeholder": "Select…"
},
"auto_update_to_latest": "Automatically update to latest version",
"basic_info": "Basic Info",
"bun_required_message": "Bun environment is required to run CLI tools",
"can_upgrade": "Upgrade available",
"cli_tool": "CLI Tool",
"cli_tool_placeholder": "Select the CLI tool to use",
"config_name": "Name",
"config_name_placeholder": "e.g. Work Claude",
"configure": "Configure",
"configuring_provider": "Configure {{provider}}",
"count_one": "{{count}} item",
"count_other": "{{count}} items",
"current_config": "Current",
"current_config_settings": "Current Config",
"custom_path": "Custom path",
"custom_path_error": "Failed to set custom terminal path",
"custom_path_required": "Custom path required for this terminal",
"custom_path_set": "Custom terminal path set successfully",
"description": "Quickly launch multiple code CLI tools to improve development efficiency",
"duplicate": "Duplicate",
"duplicate_success": "Duplicated",
"edit_config": "Edit Config",
"enable": "Enable",
"enabled": "Enabled",
"endpoint_key_in_model_service": "Endpoint / Key in Model Service",
"env_vars_help": "Enter custom environment variables (one per line, format: KEY=value)",
"environment_variables": "Environment Variables",
"folder_placeholder": "Select working directory",
"hero_tagline": "Pick a CLI tool to configure",
"install": "Install",
"install_bun": "Install Bun",
"install_error": "Installation failed",
"install_success": "Installation successful",
"installing": "Installing…",
"installing_bun": "Installing...",
"latest": "Latest",
"launch": {
"bun_required": "Please install Bun environment first before launching CLI tools",
"error": "Launch failed, please try again",
@@ -1961,9 +2024,18 @@
"launching": "Launching...",
"model": "Model",
"model_hint": "Choose which AI model the CLI tool should use",
"model_hint_config": "Select the model to use",
"model_placeholder": "Select the model to use",
"model_required": "Please select a model",
"no_configs_description": "Add a named config to manage multiple providers/models for this CLI tool",
"no_configs_title": "No configs yet",
"no_matching_tools": "No matching tools",
"no_tools": "No tools available",
"not_installed": "Not installed",
"providers": "Providers",
"search_cli_placeholder": "Search CLI…",
"select_folder": "Select Folder",
"select_tool_to_start": "Select a CLI tool from the left to start configuring",
"set_custom_path": "Set custom terminal path",
"supported_providers": "Supported Providers",
"terminal": "Terminal",
@@ -1972,17 +2044,18 @@
"title": "Code Tools",
"tool_description": {
"claude_code": "Anthropic's official command-line AI coding assistant",
"gemini_cli": "Google's official Gemini command-line assistant",
"github_copilot_cli": "GitHub's official Copilot command-line companion",
"kimi_cli": "Moonshot's Kimi command-line assistant",
"hermes": "Nous Research agentic coding CLI",
"openai_codex": "OpenAI's official code generation CLI",
"opencode": "Open-source multi-model coding agent",
"qoder_cli": "Alibaba Qoder's agentic command-line coding tool",
"qwen_code": "Alibaba Tongyi's open-source CLI coding tool"
"openclaw": "Multi-channel personal AI assistant with gateway service",
"opencode": "Open-source multi-model coding agent"
},
"update_options": "Update Options",
"upgrade": "Upgrade",
"upgrade_error": "Upgrade failed",
"upgrade_success": "Upgrade successful",
"working_directory": "Working Directory",
"working_directory_hint": "The working directory that the CLI tool launches in"
"working_directory_hint": "The working directory that the CLI tool launches in",
"advanced_config": "Advanced Config"
},
"code_block": {
"collapse": "Collapse",
@@ -2496,6 +2569,9 @@
},
"title": "GPUStack"
},
"hermes": {
"title": "Hermes"
},
"history": {
"continue_chat": "Continue Chatting",
"error": {
@@ -6647,7 +6723,6 @@
"sync_added_metric": "{{count}} new models",
"sync_added_section": "New models",
"sync_apply_changes": "Apply changes",
"sync_apply_default_in_use": "Some models are in use as the default model and cannot be removed.",
"sync_apply_result": "Added {{added}}, deprecated {{deprecated}}, deleted {{deleted}}.",
"sync_empty_added": "No new upstream models were found.",
"sync_empty_missing": "No unavailable local models were found.",

View File

@@ -1933,23 +1933,86 @@
}
},
"code": {
"add_config": "添加",
"add_provider_hint": "在 设置 → 模型服务 添加服务商",
"adv": {
"claude": {
"auto_compact_window": "自动压缩窗口",
"effort_level": "推理努力程度",
"enable_tool_search": "启用工具搜索",
"include_co_authored_by": "包含共同作者",
"max_output_tokens": "最大输出 Token",
"skip_web_fetch_preflight": "跳过 Web 预检",
"timeout_ms": "API 超时 (ms)",
"disable_nonessential_traffic": "禁用非必要流量",
"disable_experimental_betas": "禁用实验性 Beta"
},
"codex": {
"auto_compact_token_limit": "自动压缩 Token 限制",
"context_window": "上下文窗口",
"disable_response_storage": "禁用响应存储",
"personality": "个性",
"reasoning_effort": "推理努力程度",
"review_model": "审查模型",
"verbosity": "详细程度"
},
"effort": {
"high": "高",
"low": "低",
"medium": "中"
},
"hermes": {
"context_length": "上下文长度",
"max_tokens": "最大 Token"
},
"openclaw": {
"context_window": "上下文窗口",
"max_tokens": "最大 Token",
"reasoning": "启用推理"
},
"opencode": {
"budget_tokens": "推理预算 Token",
"context_limit": "上下文限制",
"output_limit": "输出限制"
},
"select_placeholder": "请选择…"
},
"auto_update_to_latest": "检查更新并安装最新版本",
"basic_info": "基本信息",
"bun_required_message": "运行 CLI 工具需要安装 Bun 环境",
"can_upgrade": "可升级",
"cli_tool": "CLI 工具",
"cli_tool_placeholder": "选择要使用的 CLI 工具",
"config_name": "名称",
"config_name_placeholder": "例如:工作 Claude",
"configure": "配置",
"configuring_provider": "配置 {{provider}}",
"count_one": "{{count}} 个",
"count_other": "{{count}} 个",
"current_config": "当前",
"current_config_settings": "当前配置",
"custom_path": "自定义路径",
"custom_path_error": "设置自定义终端路径失败",
"custom_path_required": "此终端需要设置自定义路径",
"custom_path_set": "自定义终端路径设置成功",
"description": "快速启动多个代码 CLI 工具,提高开发效率",
"duplicate": "复制",
"duplicate_success": "已复制",
"edit_config": "编辑配置",
"enable": "启用",
"enabled": "已启用",
"endpoint_key_in_model_service": "端点 / Key 在 模型服务",
"env_vars_help": "输入自定义环境变量每行一个格式KEY=value",
"environment_variables": "环境变量",
"folder_placeholder": "选择工作目录",
"hero_tagline": "选一个 CLI 工具开始配置",
"install": "安装",
"install_bun": "安装 Bun",
"install_error": "安装失败",
"install_success": "安装成功",
"installing": "安装中…",
"installing_bun": "安装中...",
"latest": "最新",
"launch": {
"bun_required": "请先安装 Bun 环境再启动 CLI 工具",
"error": "启动失败,请重试",
@@ -1961,9 +2024,18 @@
"launching": "启动中...",
"model": "模型",
"model_hint": "为 CLI 工具选择要使用的 AI 模型",
"model_hint_config": "选择要使用的模型",
"model_placeholder": "选择要使用的模型",
"model_required": "请选择模型",
"no_configs_description": "添加命名配置以管理此 CLI 工具的多个供应商/模型",
"no_configs_title": "暂无配置",
"no_matching_tools": "没有匹配的工具",
"no_tools": "暂无工具",
"not_installed": "未安装",
"providers": "服务商",
"search_cli_placeholder": "搜索 CLI…",
"select_folder": "选择文件夹",
"select_tool_to_start": "从左侧选择一个 CLI 工具开始配置",
"set_custom_path": "设置自定义终端路径",
"supported_providers": "支持的服务商",
"terminal": "终端",
@@ -1972,17 +2044,18 @@
"title": "代码工具",
"tool_description": {
"claude_code": "Anthropic 官方 CLI 编程助手",
"gemini_cli": "Google Gemini 命令行助手",
"github_copilot_cli": "GitHub Copilot 命令行版本",
"kimi_cli": "Moonshot Kimi 命令行助手",
"hermes": "Nous Research 智能编程 CLI",
"openai_codex": "OpenAI 官方代码生成 CLI",
"opencode": "开源社区的多模型代码 Agent",
"qoder_cli": "阿里 Qoder 的智能体命令行编程工具",
"qwen_code": "阿里通义出品的开源 CLI 编程工具"
"openclaw": "多通道个人 AI 助手,支持 Gateway 常驻服务",
"opencode": "开源社区的多模型代码 Agent"
},
"update_options": "更新选项",
"upgrade": "升级",
"upgrade_error": "升级失败",
"upgrade_success": "升级成功",
"working_directory": "工作目录",
"working_directory_hint": "CLI 工具启动时的工作目录"
"working_directory_hint": "CLI 工具启动时的工作目录",
"advanced_config": "高级配置"
},
"code_block": {
"collapse": "收起",
@@ -2496,6 +2569,9 @@
},
"title": "GPUStack"
},
"hermes": {
"title": "Hermes"
},
"history": {
"continue_chat": "继续聊天",
"error": {
@@ -6647,7 +6723,6 @@
"sync_added_metric": "{{count}} 个新增模型",
"sync_added_section": "新增模型",
"sync_apply_changes": "确认应用",
"sync_apply_default_in_use": "部分模型正被用作默认模型,无法删除。",
"sync_apply_result": "已新增 {{added}} 个,标记弃用 {{deprecated}} 个,删除 {{deleted}} 个。",
"sync_empty_added": "没有发现新增的上游模型。",
"sync_empty_missing": "没有发现已失效的本地模型。",

View File

@@ -1933,23 +1933,86 @@
}
},
"code": {
"add_config": "新增",
"add_provider_hint": "[to be translated]:Add provider in Settings → Model Service",
"adv": {
"claude": {
"auto_compact_window": "自動壓縮視窗",
"effort_level": "推理努力程度",
"enable_tool_search": "啟用工具搜尋",
"include_co_authored_by": "包含共同作者",
"max_output_tokens": "最大輸出 Token",
"skip_web_fetch_preflight": "略過 Web 預檢",
"timeout_ms": "API 逾時 (ms)",
"disable_nonessential_traffic": "停用非必要流量",
"disable_experimental_betas": "停用實驗性 Beta"
},
"codex": {
"auto_compact_token_limit": "自動壓縮 Token 限制",
"context_window": "上下文視窗",
"disable_response_storage": "停用回應儲存",
"personality": "個性",
"reasoning_effort": "推理努力程度",
"review_model": "審查模型",
"verbosity": "詳細程度"
},
"effort": {
"high": "高",
"low": "低",
"medium": "中"
},
"hermes": {
"context_length": "上下文長度",
"max_tokens": "最大 Token"
},
"openclaw": {
"context_window": "上下文視窗",
"max_tokens": "最大 Token",
"reasoning": "啟用推理"
},
"opencode": {
"budget_tokens": "推理預算 Token",
"context_limit": "上下文限制",
"output_limit": "輸出限制"
},
"select_placeholder": "請選擇…"
},
"auto_update_to_latest": "檢查更新並安裝最新版本",
"basic_info": "基本資訊",
"bun_required_message": "運作 CLI 工具需要安裝 Bun 環境",
"can_upgrade": "[to be translated]:Upgrade available",
"cli_tool": "CLI 工具",
"cli_tool_placeholder": "選擇要使用的 CLI 工具",
"config_name": "名稱",
"config_name_placeholder": "例如:工作 Claude",
"configure": "[to be translated]:Configure",
"configuring_provider": "[to be translated]:Configure {{provider}}",
"count_one": "{{count}} 個",
"count_other": "{{count}} 個",
"current_config": "目前",
"current_config_settings": "目前設定",
"custom_path": "自訂路徑",
"custom_path_error": "設定自訂終端機路徑失敗",
"custom_path_required": "此終端機需要設定自訂路徑",
"custom_path_set": "自訂終端機路徑設定成功",
"description": "快速啟動多個程式碼 CLI 工具,提高開發效率",
"duplicate": "複製",
"duplicate_success": "已複製",
"edit_config": "編輯設定",
"enable": "[to be translated]:Enable",
"enabled": "[to be translated]:Enabled",
"endpoint_key_in_model_service": "[to be translated]:Endpoint / Key in Model Service",
"env_vars_help": "輸入自訂環境變數每行一個格式KEY=value",
"environment_variables": "環境變數",
"folder_placeholder": "選擇工作目錄",
"hero_tagline": "選一個 CLI 工具開始設定",
"install": "[to be translated]:Install",
"install_bun": "安裝 Bun",
"install_error": "[to be translated]:Installation failed",
"install_success": "[to be translated]:Installation successful",
"installing": "[to be translated]:Installing…",
"installing_bun": "安裝中...",
"latest": "[to be translated]:Latest",
"launch": {
"bun_required": "請先安裝 Bun 環境再啟動 CLI 工具",
"error": "啟動失敗,請重試",
@@ -1961,9 +2024,18 @@
"launching": "啟動中...",
"model": "模型",
"model_hint": "為 CLI 工具選擇要使用的 AI 模型",
"model_hint_config": "[to be translated]:Select the model to use",
"model_placeholder": "選擇要使用的模型",
"model_required": "請選擇模型",
"no_configs_description": "新增命名設定以管理此 CLI 工具的多個供應商/模型",
"no_configs_title": "尚無設定",
"no_matching_tools": "[to be translated]:No matching tools",
"no_tools": "[to be translated]:No tools available",
"not_installed": "[to be translated]:Not installed",
"providers": "[to be translated]:Providers",
"search_cli_placeholder": "[to be translated]:Search CLI…",
"select_folder": "選擇資料夾",
"select_tool_to_start": "[to be translated]:Select a CLI tool from the left to start configuring",
"set_custom_path": "設定自訂終端機路徑",
"supported_providers": "支援的供應商",
"terminal": "終端機",
@@ -1972,17 +2044,18 @@
"title": "程式碼工具",
"tool_description": {
"claude_code": "Anthropic 官方 CLI 程式助理",
"gemini_cli": "Google Gemini 命令列助理",
"github_copilot_cli": "GitHub Copilot 命令列版本",
"kimi_cli": "Moonshot Kimi 命令列助理",
"hermes": "Nous Research 智能編程 CLI",
"openai_codex": "OpenAI 官方程式碼產生 CLI",
"opencode": "開源社群的多模型程式碼 Agent",
"qoder_cli": "阿里 Qoder 的智慧體命令列程式設計工具",
"qwen_code": "阿里通義出品的開源 CLI 程式設計工具"
"openclaw": "多通道個人 AI 助手,支援 Gateway 常駐服務",
"opencode": "開源社群的多模型程式碼 Agent"
},
"update_options": "更新選項",
"upgrade": "[to be translated]:Upgrade",
"upgrade_error": "[to be translated]:Upgrade failed",
"upgrade_success": "[to be translated]:Upgrade successful",
"working_directory": "工作目錄",
"working_directory_hint": "CLI 工具啟動時的工作目錄"
"working_directory_hint": "CLI 工具啟動時的工作目錄",
"advanced_config": "進階設定"
},
"code_block": {
"collapse": "收合",
@@ -2496,6 +2569,9 @@
},
"title": "GPUStack"
},
"hermes": {
"title": "Hermes"
},
"history": {
"continue_chat": "繼續聊天",
"error": {
@@ -3708,11 +3784,11 @@
"deleting": "刪除",
"documentFiles": "文件",
"download": "下載",
"downloading": "下載中",
"executeCommand": "執行命令",
"executingCommand": "執行命令",
"downloading": "[to be translated]:Downloading",
"executeCommand": "[to be translated]:Run command",
"executingCommand": "[to be translated]:Running command",
"extract": "擷取",
"extracting": "提取",
"extracting": "[to be translated]:Extracting",
"file": "檔案",
"fileList": "檔案清單",
"folder": "資料夾",
@@ -6647,7 +6723,6 @@
"sync_added_metric": "{{count}} 個新增模型",
"sync_added_section": "新增模型",
"sync_apply_changes": "確認套用",
"sync_apply_default_in_use": "部分模型正被用作預設模型,無法刪除。",
"sync_apply_result": "已新增 {{added}} 個,標記棄用 {{deprecated}} 個,刪除 {{deleted}} 個。",
"sync_empty_added": "沒有發現新增的上游模型。",
"sync_empty_missing": "沒有發現已失效的本機模型。",
@@ -6767,11 +6842,11 @@
"addToolDescription": "使用 mise 工具鍵新增工具(例如 github:sharkdp/fd、uv、bun。",
"coreDepsMissing": "核心依賴未安裝",
"customTools": "自訂工具",
"customToolsEmpty": "尚無自訂工具。請新增一個以開始使用。",
"customToolsEmpty": "[to be translated]:No custom tools yet. Add one to get started.",
"description": "管理應用程式執行所需的二進位工具和執行階段依賴。",
"duplicateName": "已存在相同名稱的工具",
"fieldVersion": "版本(選填,預設為最新)",
"install": "安裝",
"install": "[to be translated]:Install",
"installError": "工具安裝失敗",
"installing": "安裝中...",
"invalidTool": "工具名稱或識別碼無效",

File diff suppressed because it is too large Load Diff

View File

@@ -1,433 +0,0 @@
import '@testing-library/jest-dom/vitest'
import type { EndpointType, Model } from '@shared/data/types/model'
import { ENDPOINT_TYPE, MODEL_CAPABILITY } from '@shared/data/types/model'
import type { Provider } from '@shared/data/types/provider'
import { CodeCli, TerminalApp } from '@shared/types/codeCli'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import type React from 'react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const testState = vi.hoisted(() => ({
isBunInstalled: true,
selectedCliTool: 'github-copilot-cli',
selectedModel: null as string | null,
canLaunch: true,
codeCliRun: vi.fn(),
setModel: vi.fn(),
setTimeoutTimer: vi.fn(),
providers: [] as Provider[],
models: [] as Model[],
modelSelectorProps: [] as any[]
}))
import CodeCliPage from '../CodeCliPage'
vi.mock('@cherrystudio/ui', async () => {
const React = await import('react')
return {
Button: ({ children, loading, ...props }: React.ButtonHTMLAttributes<HTMLButtonElement> & { loading?: boolean }) =>
React.createElement('button', { type: 'button', ...props, disabled: props.disabled || loading }, children),
Checkbox: ({
className,
id,
onCheckedChange
}: {
className?: string
id?: string
onCheckedChange?: (v: boolean) => void
}) =>
React.createElement('button', {
id,
type: 'button',
role: 'checkbox',
className,
onClick: () => onCheckedChange?.(true)
}),
Label: ({ children, htmlFor, className }: { children: React.ReactNode; htmlFor?: string; className?: string }) =>
React.createElement('label', { htmlFor, className }, children),
Dialog: ({ open, children }: { open: boolean; children: React.ReactNode }) =>
open ? React.createElement('div', { role: 'dialog' }, children) : null,
DialogContent: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
DialogHeader: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
DialogTitle: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
DialogFooter: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
DialogClose: ({ children }: { children: React.ReactNode }) => React.createElement(React.Fragment, null, children),
SelectDropdown: () => React.createElement('div', null),
Textarea: {
Input: ({ value, onValueChange }: { value?: string; onValueChange?: (value: string) => void }) =>
React.createElement('textarea', {
value,
onChange: (event: React.ChangeEvent<HTMLTextAreaElement>) => onValueChange?.(event.currentTarget.value)
})
}
}
})
vi.mock('@renderer/components/app/Navbar', () => ({
Navbar: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
NavbarCenter: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
vi.mock('@renderer/components/Avatar/ModelAvatar', () => ({
default: () => null
}))
vi.mock('@renderer/components/Selector/model', async () => {
const React = await import('react')
return {
ModelSelector: (props: any) => {
testState.modelSelectorProps.push(props)
return React.createElement(
'div',
{ 'data-testid': 'code-model-selector' },
props.trigger,
React.createElement(
'button',
{
type: 'button',
onClick: () => props.onSelect('openai::gpt-4o')
},
'select mock model'
)
)
}
}
})
vi.mock('@renderer/utils/platform', () => ({
isMac: false,
isWin: false
}))
vi.mock('@renderer/ipc', () => ({
ipcApi: {
request: vi.fn().mockResolvedValue({ version: '1.0.0' })
}
}))
vi.mock('@renderer/data/hooks/useCache', () => ({
usePersistCache: () => [testState.isBunInstalled, vi.fn()]
}))
vi.mock('@renderer/hooks/useCodeCli', () => ({
useCodeCli: () => ({
selectedCliTool: testState.selectedCliTool as CodeCli,
selectedModel: testState.selectedModel,
selectedTerminal: TerminalApp.SYSTEM_DEFAULT,
environmentVariables: '',
directories: [],
currentDirectory: '',
canLaunch: testState.canLaunch,
setCliTool: vi.fn().mockResolvedValue(undefined),
setModel: testState.setModel,
setTerminal: vi.fn(),
setEnvVars: vi.fn(),
setCurrentDir: vi.fn().mockResolvedValue(undefined),
removeDir: vi.fn().mockResolvedValue(undefined),
selectFolder: vi.fn().mockResolvedValue(undefined)
})
}))
vi.mock('@renderer/hooks/useProvider', () => ({
useProviders: () => ({ providers: testState.providers }),
getProviderDisplayName: (provider: { name?: string; id?: string }) => provider?.name ?? provider?.id ?? ''
}))
vi.mock('@renderer/hooks/useModel', () => ({
useModels: () => ({ models: testState.models })
}))
vi.mock('@renderer/hooks/useTimer', () => ({
useTimer: () => ({ setTimeoutTimer: testState.setTimeoutTimer })
}))
vi.mock('@renderer/services/LoggerService', () => ({
loggerService: {
withContext: () => ({
error: vi.fn(),
info: vi.fn(),
warn: vi.fn()
})
}
}))
vi.mock('@shared/config/providers', () => ({
CLAUDE_OFFICIAL_SUPPORTED_PROVIDERS: [],
isSiliconAnthropicCompatibleModel: () => false
}))
vi.mock('react-i18next', () => ({
initReactI18next: {
type: '3rdParty',
init: vi.fn()
},
useTranslation: () => ({ t: (key: string) => key })
}))
vi.mock('../components/CodeToolGallery', () => ({
CodeToolGallery: ({
tools,
handleSelectTool
}: {
tools: Array<{ value: CodeCli; label: string }>
handleSelectTool: (value: CodeCli) => void
}) => (
<button type="button" onClick={() => handleSelectTool(tools[0].value)}>
open tool
</button>
)
}))
vi.mock('../components/FieldLabel', () => ({
FieldLabel: ({ children }: { children: React.ReactNode }) => <div>{children}</div>
}))
beforeEach(() => {
vi.clearAllMocks()
testState.isBunInstalled = true
testState.selectedCliTool = CodeCli.GITHUB_COPILOT_CLI
testState.selectedModel = null
testState.canLaunch = true
testState.codeCliRun.mockResolvedValue({ success: true })
testState.setModel.mockResolvedValue(undefined)
testState.providers = []
testState.models = []
testState.modelSelectorProps = []
Object.assign(window, {
api: {
isBinaryExist: vi.fn().mockResolvedValue(true),
codeCli: {
getAvailableTerminals: vi.fn().mockResolvedValue([]),
run: testState.codeCliRun
}
},
toast: {
error: vi.fn(),
success: vi.fn(),
warning: vi.fn()
}
})
})
async function openCodeToolDialog() {
render(<CodeCliPage />)
await waitFor(() => expect(window.api.isBinaryExist).toHaveBeenCalledWith('bun'))
fireEvent.click(screen.getByRole('button', { name: 'open tool' }))
await waitFor(() => expect(screen.getByRole('dialog')).toBeInTheDocument())
}
function makeProvider(
id: string,
defaultChatEndpoint: EndpointType | undefined = ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS,
overrides: Partial<Provider> = {}
): Provider {
return {
id,
name: id,
apiKeys: [],
authType: 'api-key',
defaultChatEndpoint,
endpointConfigs: {},
apiFeatures: {},
settings: {},
isEnabled: true,
...overrides
} as Provider
}
function makeModel(id: string, providerId: string, overrides: Partial<Model> = {}): Model {
return {
id,
providerId,
name: id.split('::')[1] ?? id,
capabilities: [],
supportsStreaming: true,
isEnabled: true,
isHidden: false,
...overrides
} as Model
}
function latestModelSelectorProps() {
return testState.modelSelectorProps.at(-1)
}
describe('CodeCliPage', () => {
it('uses the shared model selector for non-copilot tools and writes selected ids back', async () => {
testState.selectedCliTool = CodeCli.QWEN_CODE
await openCodeToolDialog()
expect(screen.getByTestId('code-model-selector')).toBeInTheDocument()
expect(latestModelSelectorProps()).toMatchObject({
multiple: false,
selectionType: 'id',
showTagFilter: false
})
await waitFor(() => expect(latestModelSelectorProps().portalContainer).toBeInstanceOf(HTMLElement))
expect(latestModelSelectorProps().portalContainer.closest('[role="dialog"]')).toBe(screen.getByRole('dialog'))
fireEvent.click(screen.getByRole('button', { name: 'select mock model' }))
await waitFor(() => expect(testState.setModel).toHaveBeenCalledWith('openai::gpt-4o'))
})
it('does not pass malformed stored model ids to the shared model selector', async () => {
testState.selectedCliTool = CodeCli.QWEN_CODE
testState.selectedModel = 'legacy-model-id'
await openCodeToolDialog()
expect(latestModelSelectorProps().value).toBeUndefined()
})
it('keeps the code-cli provider and model filter when using the shared model selector', async () => {
testState.selectedCliTool = CodeCli.QWEN_CODE
testState.providers = [
makeProvider('openai'),
makeProvider('anthropic', ENDPOINT_TYPE.ANTHROPIC_MESSAGES),
makeProvider('cherryai')
]
const chatModel = makeModel('openai::gpt-4o', 'openai', {
endpointTypes: [ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS]
})
const anthropicModel = makeModel('anthropic::claude-3-5-sonnet', 'anthropic', {
endpointTypes: [ENDPOINT_TYPE.ANTHROPIC_MESSAGES]
})
const embeddingModel = makeModel('openai::text-embedding-3-small', 'openai', {
capabilities: [MODEL_CAPABILITY.EMBEDDING],
endpointTypes: [ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS]
})
const rerankModel = makeModel('openai::rerank', 'openai', {
capabilities: [MODEL_CAPABILITY.RERANK],
endpointTypes: [ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS]
})
const imageModel = makeModel('openai::image', 'openai', {
capabilities: [MODEL_CAPABILITY.IMAGE_GENERATION],
endpointTypes: [ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS]
})
const cherryAiModel = makeModel('cherryai::gpt-4o', 'cherryai', {
endpointTypes: [ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS]
})
await openCodeToolDialog()
const filter = latestModelSelectorProps().filter as (model: Model) => boolean
expect(filter(chatModel)).toBe(true)
expect(filter(anthropicModel)).toBe(false)
expect(filter(embeddingModel)).toBe(false)
expect(filter(rerankModel)).toBe(false)
expect(filter(imageModel)).toBe(false)
expect(filter(cherryAiModel)).toBe(false)
})
it('keeps the OpenCode provider fallback equivalent to the pre-v2 frontend filter', async () => {
testState.selectedCliTool = CodeCli.OPEN_CODE
testState.providers = [
makeProvider('openai-chat', ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS),
makeProvider('new-api', ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS, {
presetProviderId: 'new-api',
defaultChatEndpoint: undefined
}),
makeProvider('anthropic', ENDPOINT_TYPE.ANTHROPIC_MESSAGES),
makeProvider('gateway', ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS, {
presetProviderId: 'gateway',
defaultChatEndpoint: undefined
})
]
await openCodeToolDialog()
const filter = latestModelSelectorProps().filter as (model: Model) => boolean
expect(filter(makeModel('openai-chat::gpt-4o', 'openai-chat'))).toBe(true)
expect(filter(makeModel('new-api::claude-3-5-sonnet', 'new-api'))).toBe(true)
expect(filter(makeModel('anthropic::claude-3-5-sonnet', 'anthropic'))).toBe(true)
expect(
filter(
makeModel('gateway::gpt-4o', 'gateway', {
endpointTypes: [ENDPOINT_TYPE.OPENAI_CHAT_COMPLETIONS]
})
)
).toBe(false)
})
it('does not render the model selector for GitHub Copilot CLI', async () => {
testState.selectedCliTool = CodeCli.GITHUB_COPILOT_CLI
await openCodeToolDialog()
expect(screen.queryByTestId('code-model-selector')).not.toBeInTheDocument()
})
it('keeps the auto-update checkbox neutral instead of primary themed', async () => {
await openCodeToolDialog()
const checkbox = await screen.findByRole('checkbox')
// Behavioral guard: page must not theme the auto-update checkbox with the global primary token.
expect(checkbox.className).not.toMatch(/primary/)
expect(screen.getByText('code.auto_update_to_latest')).toHaveClass('font-normal')
})
it('disables launch when the tool cannot launch', async () => {
testState.canLaunch = false
await openCodeToolDialog()
expect(screen.getByRole('button', { name: 'code.launch.label' })).toBeDisabled()
})
it('disables launch when bun is not installed', async () => {
testState.isBunInstalled = false
await openCodeToolDialog()
expect(screen.getByRole('button', { name: 'code.launch.label' })).toBeDisabled()
})
it('shows launching state and prevents duplicate launch submissions', async () => {
let resolveRun!: (value: { success: boolean }) => void
testState.codeCliRun.mockReturnValue(
new Promise<{ success: boolean }>((resolve) => {
resolveRun = resolve
})
)
await openCodeToolDialog()
const launchButton = screen.getByRole('button', { name: 'code.launch.label' })
fireEvent.click(launchButton)
const launchingButton = await screen.findByRole('button', { name: 'code.launching' })
expect(launchingButton).toBeDisabled()
fireEvent.click(launchingButton)
expect(testState.codeCliRun).toHaveBeenCalledTimes(1)
resolveRun({ success: true })
await waitFor(() => expect(window.toast.success).toHaveBeenCalledWith('code.launch.success'))
})
it('shows launched state after a successful launch and schedules reset', async () => {
await openCodeToolDialog()
fireEvent.click(screen.getByRole('button', { name: 'code.launch.label' }))
expect(await screen.findByRole('button', { name: /code.launch.launched/ })).toBeEnabled()
expect(testState.setTimeoutTimer).toHaveBeenCalledWith('launchSuccess', expect.any(Function), 2500)
})
it('returns to idle and shows an error when launch fails', async () => {
testState.codeCliRun.mockResolvedValue({ success: false, message: 'launch failed' })
await openCodeToolDialog()
fireEvent.click(screen.getByRole('button', { name: 'code.launch.label' }))
await waitFor(() => expect(window.toast.error).toHaveBeenCalledWith('launch failed'))
expect(screen.getByRole('button', { name: 'code.launch.label' })).toBeEnabled()
})
})

View File

@@ -1,7 +1,7 @@
import { CodeCli } from '@shared/types/codeCli'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { CLI_TOOLS, generateToolEnvironment, type ToolEnvironmentConfig } from '../index'
import { CLI_TOOLS, generateProviderConfig, type ToolEnvironmentConfig } from '../index'
// Mock CodeCliPage which is default export
vi.mock('../CodeCliPage', () => ({ default: () => null }))
@@ -9,14 +9,14 @@ vi.mock('../CodeCliPage', () => ({ default: () => null }))
// Mock dependencies needed by CodeCliPage
vi.mock('@renderer/hooks/useCodeCli', () => ({
useCodeCli: () => ({
selectedCliTool: CodeCli.QWEN_CODE,
selectedCliTool: codeCLI.claudeCode,
selectedModel: null,
selectedTerminal: 'systemDefault',
environmentVariables: '',
directories: [],
currentDirectory: '',
canLaunch: true,
setCliTool: vi.fn(),
selectTool: vi.fn(),
setModel: vi.fn(),
setTerminal: vi.fn(),
setEnvVars: vi.fn(),
@@ -59,7 +59,7 @@ vi.mock('react-i18next', async (importOriginal) => {
}
})
describe('generateToolEnvironment', () => {
describe('generateProviderConfig', () => {
const baseConfig = (
overrides: Partial<ToolEnvironmentConfig> & Pick<ToolEnvironmentConfig, 'tool' | 'baseUrl'>
): ToolEnvironmentConfig => ({
@@ -76,66 +76,58 @@ describe('generateToolEnvironment', () => {
vi.clearAllMocks()
})
it('should format baseUrl with /v1 for qwenCode when missing', () => {
const { env } = generateToolEnvironment(
baseConfig({ tool: CodeCli.QWEN_CODE, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode' })
it('should format baseUrl with /v1 for claudeCode when missing', () => {
const config = generateProviderConfig(
baseConfig({ tool: codeCLI.claudeCode, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode' })
)
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
expect(config.baseUrl).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
})
it('should not duplicate /v1 when already present for qwenCode', () => {
const { env } = generateToolEnvironment(
baseConfig({ tool: CodeCli.QWEN_CODE, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' })
it('should not duplicate /v1 when already present for claudeCode', () => {
const config = generateProviderConfig(
baseConfig({ tool: codeCLI.claudeCode, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1' })
)
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
expect(config.baseUrl).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
})
it('should handle empty baseUrl gracefully', () => {
const { env } = generateToolEnvironment(baseConfig({ tool: CodeCli.QWEN_CODE, baseUrl: '' }))
const config = generateProviderConfig(baseConfig({ tool: codeCLI.claudeCode, baseUrl: '' }))
expect(env.OPENAI_BASE_URL).toBe('')
expect(config.baseUrl).toBe('')
})
it('should preserve other API versions when present', () => {
const { env } = generateToolEnvironment(
baseConfig({ tool: CodeCli.QWEN_CODE, baseUrl: 'https://dashscope.aliyuncs.com/v2' })
const config = generateProviderConfig(
baseConfig({ tool: codeCLI.claudeCode, baseUrl: 'https://dashscope.aliyuncs.com/v2' })
)
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/v2')
expect(config.baseUrl).toBe('https://dashscope.aliyuncs.com/v2')
})
it('should format baseUrl with /v1 for openaiCodex when missing', () => {
const { env } = generateToolEnvironment(
baseConfig({ tool: CodeCli.OPENAI_CODEX, providerId: 'openai', baseUrl: 'https://api.openai.com' })
const config = generateProviderConfig(
baseConfig({ tool: codeCLI.openaiCodex, providerId: 'openai', baseUrl: 'https://api.openai.com' })
)
expect(env.CHERRY_CODEX_BASE_URL).toBe('https://api.openai.com/v1')
})
it('should inject QODERCN_PERSONAL_ACCESS_TOKEN for qoderCli', () => {
const { env } = generateToolEnvironment(
baseConfig({ tool: CodeCli.QODER_CLI, providerId: 'qoder', apiKey: 'test-key', baseUrl: 'https://api.qoder.com' })
)
expect(env.QODERCN_PERSONAL_ACCESS_TOKEN).toBe('test-key')
expect(config.baseUrl).toBe('https://api.openai.com/v1')
})
it('should handle trailing slash correctly', () => {
const { env } = generateToolEnvironment(
baseConfig({ tool: CodeCli.QWEN_CODE, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/' })
const config = generateProviderConfig(
baseConfig({ tool: codeCLI.claudeCode, baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/' })
)
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
expect(config.baseUrl).toBe('https://dashscope.aliyuncs.com/compatible-mode/v1')
})
it('should handle v2beta version correctly', () => {
const { env } = generateToolEnvironment(
baseConfig({ tool: CodeCli.QWEN_CODE, baseUrl: 'https://dashscope.aliyuncs.com/v2beta' })
const config = generateProviderConfig(
baseConfig({ tool: codeCLI.claudeCode, baseUrl: 'https://dashscope.aliyuncs.com/v2beta' })
)
expect(env.OPENAI_BASE_URL).toBe('https://dashscope.aliyuncs.com/v2beta')
expect(config.baseUrl).toBe('https://dashscope.aliyuncs.com/v2beta')
})
})

View File

@@ -0,0 +1,31 @@
import { ClaudeCode, Nousresearch, OpenaiCodex, Openclaw, OpenCode } from '@cherrystudio/ui/icons'
import { codeCLI } from '@shared/types/codeCli'
import type { FC } from 'react'
const CLI_ICONS: Record<string, React.ComponentType<{ size?: number; className?: string }>> = {
[codeCLI.claudeCode]: ClaudeCode,
[codeCLI.openaiCodex]: OpenaiCodex,
[codeCLI.openCode]: OpenCode,
[codeCLI.openclaw]: Openclaw,
[codeCLI.hermes]: Nousresearch
}
interface CLIIconProps {
id: string
size?: number
className?: string
}
export const CLIIcon: FC<CLIIconProps> = ({ id, size = 28, className }) => {
const Icon = CLI_ICONS[id]
if (!Icon) {
return (
<div
className={`flex items-center justify-center rounded-md bg-accent/50 text-foreground/70 font-medium ${className}`}
style={{ width: size, height: size, fontSize: size * 0.4 }}>
{id.charAt(0).toUpperCase()}
</div>
)
}
return <Icon size={size} className={className} />
}

View File

@@ -0,0 +1,84 @@
import { Scrollbar, SearchInput } from '@cherrystudio/ui'
import type { codeCLI } from '@shared/types/codeCli'
import { type FC, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { CLI_TOOLS } from '..'
import { CLIIcon } from './CLIIcon'
import type { CodeToolMeta } from './types'
type CliToolOption = (typeof CLI_TOOLS)[number]
export interface CodeCliSidebarProps {
tools: readonly CliToolOption[]
selectedCliTool: codeCLI
onSelectTool: (tool: codeCLI) => void
toMeta: (tool: CliToolOption) => CodeToolMeta
}
export const CodeCliSidebar: FC<CodeCliSidebarProps> = ({ tools, selectedCliTool, onSelectTool, toMeta }) => {
const { t } = useTranslation()
const [searchTerm, setSearchTerm] = useState('')
const displayedTools = useMemo(
() =>
tools.filter(
(tool) =>
tool.label.toLowerCase().includes(searchTerm.toLowerCase()) ||
tool.value.toLowerCase().includes(searchTerm.toLowerCase())
),
[tools, searchTerm]
)
return (
<div className="relative h-full min-h-0 shrink-0 w-60">
<aside className="flex size-full min-h-0 flex-col border-border-muted border-r">
<div className="flex shrink-0 items-center gap-2 p-3">
<div className="min-w-0 flex-1">
<SearchInput
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
onClear={() => setSearchTerm('')}
clearLabel={t('common.clear')}
placeholder={t('code.search_cli_placeholder')}
/>
</div>
</div>
<Scrollbar className="min-h-0 flex-1 overflow-x-hidden px-3 pb-3">
{displayedTools.length === 0 ? (
<div className="text-center text-xs text-muted-foreground/50 py-8">
{searchTerm ? t('code.no_matching_tools') : t('code.no_tools')}
</div>
) : (
<div className="space-y-0.5">
{displayedTools.map((tool) => {
const meta = toMeta(tool)
const isSelected = selectedCliTool === tool.value
return (
<button
key={tool.value}
type="button"
onClick={() => onSelectTool(tool.value)}
className={`w-full flex items-center gap-2.5 px-2 py-1.5 rounded-lg text-left transition-colors ${
isSelected ? 'bg-accent/55' : 'hover:bg-accent/30'
}`}>
<CLIIcon id={tool.value} size={28} />
<div className="min-w-0 flex-1">
<div className="text-[13px] text-foreground truncate">{meta.label}</div>
<div className="flex items-center gap-1.5">
<span className="text-[11px] text-muted-foreground/50 truncate">
{t('code.tool_description.' + tool.value.replace(/-/g, '_'))}
</span>
</div>
</div>
</button>
)
})}
</div>
)}
</Scrollbar>
</aside>
</div>
)
}

View File

@@ -0,0 +1,94 @@
import { Badge, Button, Switch, Tooltip } from '@cherrystudio/ui'
import type { CliNamedConfig } from '@shared/data/preference/preferenceTypes'
import { Copy, Pencil, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
export interface ConfigCardProps {
config: CliNamedConfig
providerName?: string
modelName?: string
isCurrent: boolean
onEdit: (config: CliNamedConfig) => void
onDuplicate: (config: CliNamedConfig) => void
onDelete: (config: CliNamedConfig) => void
onToggleCurrent: (config: CliNamedConfig) => void
}
/** A single named-config row, modeled on the ChannelsPage instance row. */
export const ConfigCard: FC<ConfigCardProps> = ({
config,
providerName,
modelName,
isCurrent,
onEdit,
onDuplicate,
onDelete,
onToggleCurrent
}) => {
const { t } = useTranslation()
return (
<div className="group flex items-center justify-between rounded-lg border border-transparent px-3 py-2.5 transition-colors hover:border-border hover:bg-accent/40">
<div className="flex min-w-0 items-center gap-2.5">
<span className={`size-1.5 shrink-0 rounded-full ${isCurrent ? 'bg-primary' : 'bg-muted-foreground/30'}`} />
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="truncate text-foreground text-sm">{config.name}</span>
{isCurrent && (
<Badge variant="secondary" className="shrink-0 px-1.5 py-0 text-[11px] leading-4">
{t('code.current_config')}
</Badge>
)}
</div>
<div className="mt-0.5 flex items-center gap-1.5 text-[11px] text-muted-foreground/50">
{providerName && <span className="truncate">{providerName}</span>}
{modelName && (
<>
<span className="text-muted-foreground/30">·</span>
<span className="truncate font-mono">{modelName}</span>
</>
)}
{config.directory && (
<>
<span className="text-muted-foreground/30">·</span>
<span className="truncate">{config.directory}</span>
</>
)}
</div>
</div>
</div>
<div className="flex shrink-0 items-center gap-1 opacity-0 transition-opacity group-hover:opacity-100">
<Tooltip content={t('common.edit')} side="bottom">
<Button
variant="ghost"
size="icon-xs"
onClick={() => onEdit(config)}
className="size-6 text-muted-foreground/40 hover:text-foreground">
<Pencil size={10} />
</Button>
</Tooltip>
<Tooltip content={t('code.duplicate')} side="bottom">
<Button
variant="ghost"
size="icon-xs"
onClick={() => onDuplicate(config)}
className="size-6 text-muted-foreground/40 hover:text-foreground">
<Copy size={10} />
</Button>
</Tooltip>
<Tooltip content={t('common.delete')} side="bottom">
<Button
variant="ghost"
size="icon-xs"
onClick={() => onDelete(config)}
className="size-6 text-muted-foreground/40 hover:text-destructive">
<Trash2 size={10} />
</Button>
</Tooltip>
<Switch size="sm" checked={isCurrent} onCheckedChange={() => onToggleCurrent(config)} />
</div>
</div>
)
}

View File

@@ -0,0 +1,411 @@
import {
Button,
Checkbox,
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
Input,
Label,
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@cherrystudio/ui'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { ModelSelector } from '@renderer/components/Selector/model'
import { getProviderDisplayName, useProviders } from '@renderer/hooks/useProvider'
import { cn } from '@renderer/utils/style'
import type { CliNamedConfig } from '@shared/data/preference/preferenceTypes'
import { isUniqueModelId, type Model, parseUniqueModelId, type UniqueModelId } from '@shared/data/types/model'
import type { codeCLI } from '@shared/types/codeCli'
import { ChevronDown, ChevronDown as ChevronDownIcon } from 'lucide-react'
import type { FC, ReactNode } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
export interface ConfigEditPanelProps {
open: boolean
onClose: () => void
cliTool: codeCLI
/** When editing an existing config; null when adding a new one. */
config: CliNamedConfig | null
/** Model filter (provider/model compatibility) for this CLI tool. */
modelFilter: (model: Model) => boolean
onSubmit: (values: {
name: string
providerId: string
modelId: UniqueModelId
advanced?: Record<string, unknown>
}) => Promise<void>
}
export const ConfigEditPanel: FC<ConfigEditPanelProps> = ({
open,
onClose,
cliTool,
config,
modelFilter,
onSubmit
}) => {
const { t } = useTranslation()
const { providers } = useProviders()
const providerMap = useMemo(() => new Map(providers.map((p) => [p.id, p])), [providers])
const advancedFields = useMemo(() => ADVANCED_FIELDS[cliTool] ?? [], [cliTool])
const [name, setName] = useState('')
const [modelId, setModelId] = useState<UniqueModelId | undefined>(undefined)
const [advanced, setAdvanced] = useState<Record<string, unknown>>({})
const [showAdvanced, setShowAdvanced] = useState(false)
const [submitting, setSubmitting] = useState(false)
// Initialize form fields on open / config change.
useEffect(() => {
if (!open) return
if (config) {
setName(config.name)
setModelId(isUniqueModelId(config.modelId) ? config.modelId : undefined)
setAdvanced(config.advanced ?? {})
} else {
setName('')
setModelId(undefined)
setAdvanced({})
}
setShowAdvanced(false)
}, [open, config])
const selectedModelRecord = useMemo(() => {
if (!modelId) return undefined
const { providerId, modelId: rawId } = parseUniqueModelId(modelId)
const provider = providerMap.get(providerId)
return provider?.models?.find((m) => m.id === rawId)
}, [modelId, providerMap])
const selectedProvider = selectedModelRecord ? providerMap.get(selectedModelRecord.providerId) : undefined
const canSubmit = name.trim().length > 0 && !!modelId
const renderModelTrigger = () => (
<button
type="button"
className="group flex h-9 w-full items-center justify-between rounded-lg border border-border bg-muted/30 px-3 text-sm transition-colors hover:bg-muted/50">
<div className="flex min-w-0 flex-1 items-center gap-2 text-left">
{selectedModelRecord ? (
<>
<ModelAvatar model={selectedModelRecord} size={18} />
<span className="truncate text-foreground">{selectedModelRecord.name || selectedModelRecord.id}</span>
{selectedProvider && (
<span className="shrink-0 text-muted-foreground text-xs">{getProviderDisplayName(selectedProvider)}</span>
)}
</>
) : (
<span className="truncate text-muted-foreground/50">{t('code.model_placeholder')}</span>
)}
</div>
<ChevronDown
size={12}
className="ml-2 shrink-0 text-muted-foreground transition-transform group-data-[state=open]:rotate-180"
/>
</button>
)
const handleSubmit = useCallback(async () => {
if (!canSubmit || !modelId) return
try {
setSubmitting(true)
const { providerId } = parseUniqueModelId(modelId)
await onSubmit({ name: name.trim(), providerId, modelId, advanced })
onClose()
} finally {
setSubmitting(false)
}
}, [canSubmit, modelId, name, advanced, onSubmit, onClose])
const hasAdvanced = advancedFields.length > 0
return (
<Dialog open={open} onOpenChange={(o) => (!o ? onClose() : undefined)}>
<DialogContent size="lg" closeOnOverlayClick className="flex max-h-[85vh] flex-col overflow-hidden p-0">
<DialogHeader className="shrink-0 border-border border-b px-5 py-3.5">
<DialogTitle className="text-base">{config ? t('code.edit_config') : t('code.add_config')}</DialogTitle>
</DialogHeader>
<div className="scrollbar-thin min-h-0 flex-1 space-y-4 overflow-y-auto px-5 py-4">
{/* Basic info */}
<FormField label={t('code.config_name')}>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t('code.config_name_placeholder')}
/>
</FormField>
{/* Model */}
<FormField label={t('code.model')}>
<ModelSelector
multiple={false}
selectionType="id"
value={modelId}
onSelect={(id) => setModelId(id)}
filter={modelFilter}
showTagFilter
trigger={renderModelTrigger()}
portalContainer={typeof document !== 'undefined' ? document.body : null}
/>
</FormField>
{/* Advanced config */}
{hasAdvanced && (
<>
<AdvancedSettingsButton onClick={() => setShowAdvanced(!showAdvanced)}>
<ChevronDownIcon
size={16}
className={cn('transition-transform duration-200', showAdvanced && 'rotate-180')}
/>
{t('common.advanced_settings')}
</AdvancedSettingsButton>
{showAdvanced && (
<div className="grid grid-cols-1 items-start gap-x-4 gap-y-4 xl:grid-cols-2">
{advancedFields.map((field) => (
<AdvancedField
key={field.key}
field={field}
value={advanced[field.key]}
onChange={(v) => setAdvanced((prev) => ({ ...prev, [field.key]: v }))}
/>
))}
</div>
)}
</>
)}
</div>
<DialogFooter className="shrink-0 border-border border-t px-5 py-3.5">
<Button variant="outline" size="sm" onClick={onClose} disabled={submitting}>
{t('common.cancel')}
</Button>
<Button variant="default" size="sm" onClick={handleSubmit} disabled={!canSubmit} loading={submitting}>
{t('common.save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// ── Layout primitives ─────────────────────────────────────────────────────────────────
const FormField: FC<{ label: string; children: ReactNode }> = ({ label, children }) => (
<div className="flex min-w-0 flex-col gap-1.5">
<Label className="font-medium text-muted-foreground text-xs">{label}</Label>
{children}
</div>
)
/** "Advanced Settings" toggle (ghost button with a leading icon). */
const AdvancedSettingsButton: FC<React.ComponentPropsWithoutRef<typeof Button>> = ({
type = 'button',
variant = 'ghost',
size = 'sm',
className,
...props
}) => (
<Button
type={type}
variant={variant}
size={size}
className={cn('h-8 w-fit gap-1.5 px-2 text-primary hover:text-primary', className)}
{...props}
/>
)
// ── Advanced field definitions (model-agnostic params, persisted per config) ──
interface AdvancedFieldDef {
key: string
labelKey: string
type: 'text' | 'number' | 'boolean' | 'select'
placeholder?: string
options?: { value: string; labelKey: string }[]
min?: number
max?: number
}
const ADVANCED_FIELDS: Record<string, AdvancedFieldDef[]> = {
'claude-code': [
{ key: 'timeoutMs', labelKey: 'code.adv.claude.timeout_ms', type: 'text', placeholder: '30000' },
{ key: 'maxOutputTokens', labelKey: 'code.adv.claude.max_output_tokens', type: 'text', placeholder: '16384' },
{
key: 'effortLevel',
labelKey: 'code.adv.claude.effort_level',
type: 'select',
options: [
{ value: 'low', labelKey: 'code.adv.effort.low' },
{ value: 'medium', labelKey: 'code.adv.effort.medium' },
{ value: 'high', labelKey: 'code.adv.effort.high' }
]
},
{ key: 'autoCompactWindow', labelKey: 'code.adv.claude.auto_compact_window', type: 'text', placeholder: '100000' },
{ key: 'enableToolSearch', labelKey: 'code.adv.claude.enable_tool_search', type: 'boolean' },
{ key: 'skipWebFetchPreflight', labelKey: 'code.adv.claude.skip_web_fetch_preflight', type: 'boolean' },
{ key: 'includeCoAuthoredBy', labelKey: 'code.adv.claude.include_co_authored_by', type: 'boolean' },
{ key: 'disableNonessentialTraffic', labelKey: 'code.adv.claude.disable_nonessential_traffic', type: 'boolean' },
{ key: 'disableExperimentalBetas', labelKey: 'code.adv.claude.disable_experimental_betas', type: 'boolean' }
],
'openai-codex': [
{
key: 'reasoningEffort',
labelKey: 'code.adv.codex.reasoning_effort',
type: 'select',
options: [
{ value: 'low', labelKey: 'code.adv.effort.low' },
{ value: 'medium', labelKey: 'code.adv.effort.medium' },
{ value: 'high', labelKey: 'code.adv.effort.high' }
]
},
{ key: 'personality', labelKey: 'code.adv.codex.personality', type: 'text', placeholder: 'pragmatic' },
{ key: 'verbosity', labelKey: 'code.adv.codex.verbosity', type: 'text', placeholder: 'concise' },
{
key: 'contextWindow',
labelKey: 'code.adv.codex.context_window',
type: 'number',
placeholder: '128000',
min: 1000,
max: 1000000
},
{
key: 'autoCompactTokenLimit',
labelKey: 'code.adv.codex.auto_compact_token_limit',
type: 'number',
placeholder: '100000',
min: 1000,
max: 1000000
},
{ key: 'reviewModel', labelKey: 'code.adv.codex.review_model', type: 'text', placeholder: 'gpt-4o' },
{ key: 'disableResponseStorage', labelKey: 'code.adv.codex.disable_response_storage', type: 'boolean' }
],
opencode: [
{
key: 'budgetTokens',
labelKey: 'code.adv.opencode.budget_tokens',
type: 'number',
placeholder: '10000',
min: 1000,
max: 100000
},
{
key: 'contextLimit',
labelKey: 'code.adv.opencode.context_limit',
type: 'number',
placeholder: '128000',
min: 1000,
max: 1000000
},
{
key: 'outputLimit',
labelKey: 'code.adv.opencode.output_limit',
type: 'number',
placeholder: '16384',
min: 1000,
max: 100000
}
],
openclaw: [
{ key: 'reasoning', labelKey: 'code.adv.openclaw.reasoning', type: 'boolean' },
{
key: 'contextWindow',
labelKey: 'code.adv.openclaw.context_window',
type: 'number',
placeholder: '128000',
min: 1000,
max: 1000000
},
{
key: 'maxTokens',
labelKey: 'code.adv.openclaw.max_tokens',
type: 'number',
placeholder: '16384',
min: 1000,
max: 100000
}
],
hermes: [
{
key: 'contextLength',
labelKey: 'code.adv.hermes.context_length',
type: 'number',
placeholder: '128000',
min: 1000,
max: 1000000
},
{
key: 'maxTokens',
labelKey: 'code.adv.hermes.max_tokens',
type: 'number',
placeholder: '16384',
min: 1000,
max: 100000
}
]
}
// ── Field renderer ─────────────────────────────────────────────────────────
const AdvancedField: FC<{ field: AdvancedFieldDef; value: unknown; onChange: (v: unknown) => void }> = ({
field,
value,
onChange
}) => {
const { t } = useTranslation()
if (field.type === 'boolean') {
// Boolean → inline row (label + checkbox).
return (
<label
className={cn(
'flex h-14 min-w-0 flex-row items-center justify-between gap-4 rounded-md border border-border/70 px-3 xl:col-span-2'
)}>
<span className="text-foreground text-sm">{t(field.labelKey)}</span>
<Checkbox size="sm" checked={Boolean(value)} onCheckedChange={(c) => onChange(c === true)} />
</label>
)
}
if (field.type === 'select') {
return (
<FormField label={t(field.labelKey)}>
<Select value={(value as string) ?? ''} onValueChange={(v) => onChange(v)}>
<SelectTrigger className="h-9 w-full">
<SelectValue placeholder={t('code.adv.select_placeholder')} />
</SelectTrigger>
<SelectContent>
{field.options?.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{t(opt.labelKey)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
)
}
return (
<FormField label={t(field.labelKey)}>
<Input
type={field.type === 'number' ? 'number' : 'text'}
value={(value as string | number) ?? ''}
onChange={(e) =>
onChange(field.type === 'number' ? (e.target.value ? Number(e.target.value) : undefined) : e.target.value)
}
placeholder={field.placeholder}
min={field.min}
max={field.max}
className="font-mono"
/>
</FormField>
)
}

View File

@@ -0,0 +1,112 @@
import { Button } from '@cherrystudio/ui'
import { ExternalLink } from 'lucide-react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
export type ProviderHealth = 'ok' | 'degraded' | 'down'
export interface ProviderCardData {
id: string
name: string
type: string
apiHost: string
health: ProviderHealth
isActive: boolean
}
interface ProviderCardProps {
provider: ProviderCardData
onConfigure: (providerId: string) => void
onEnable: (providerId: string) => void
}
const healthMeta: Record<ProviderHealth, { dot: string; labelKey: string; defaultLabel: string }> = {
ok: { dot: 'bg-success', labelKey: 'code.health_ok', defaultLabel: '正常' },
degraded: { dot: 'bg-warning', labelKey: 'code.health_degraded', defaultLabel: '波动' },
down: { dot: 'bg-destructive', labelKey: 'code.health_down', defaultLabel: '不可用' }
}
export const ProviderCard: FC<ProviderCardProps> = ({ provider, onConfigure, onEnable }) => {
const { t } = useTranslation()
const meta = healthMeta[provider.health]
return (
<div
className={`rounded-xl border p-3.5 transition-colors ${
provider.isActive ? 'border-success/50 bg-success/[0.04]' : 'border-border/40 hover:border-border'
}`}>
<div className="flex items-center gap-3">
<span className={`w-2 h-2 rounded-full flex-shrink-0 ${meta.dot}`} />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm text-foreground truncate">{provider.name}</span>
<span className="text-[10px] px-1.5 py-0.5 rounded bg-accent/60 text-muted-foreground/60 flex-shrink-0">
{provider.type}
</span>
{provider.isActive && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-success/15 text-success flex-shrink-0">
{t('code.enabled')}
</span>
)}
</div>
<div className="text-[11px] text-muted-foreground/50 font-mono truncate mt-0.5">{provider.apiHost}</div>
</div>
<div className="flex items-center gap-1.5 flex-shrink-0">
<Button
variant="ghost"
size="sm"
onClick={() => onConfigure(provider.id)}
className="gap-1 px-2.5 py-1 text-xs rounded-md border border-border/50">
{t('code.configure')}
</Button>
{!provider.isActive && (
<Button
variant="default"
size="sm"
onClick={() => onEnable(provider.id)}
className="gap-1 px-2.5 py-1 text-xs rounded-md">
{t('code.enable')}
</Button>
)}
</div>
</div>
</div>
)
}
interface ProviderListProps {
providers: ProviderCardData[]
activeProviderId: string | null
onConfigure: (providerId: string) => void
onEnable: (providerId: string) => void
onAddProvider?: () => void
}
export const ProviderList: FC<ProviderListProps> = ({
providers,
activeProviderId,
onConfigure,
onEnable,
onAddProvider
}) => {
const { t } = useTranslation()
return (
<div className="space-y-2">
{providers.map((provider) => (
<ProviderCard
key={provider.id}
provider={{ ...provider, isActive: provider.id === activeProviderId }}
onConfigure={onConfigure}
onEnable={onEnable}
/>
))}
<button
type="button"
onClick={onAddProvider}
className="w-full flex items-center justify-center gap-1 py-2 rounded-xl border border-dashed border-border/50 text-xs text-muted-foreground/55 hover:text-foreground hover:border-border transition-colors">
{t('code.add_provider_hint')} <ExternalLink size={10} />
</button>
</div>
)
}

View File

@@ -0,0 +1,155 @@
import { Badge, Button } from '@cherrystudio/ui'
import { Download, ExternalLink, Loader2, RefreshCw, Trash2 } from 'lucide-react'
import type { FC } from 'react'
import { useTranslation } from 'react-i18next'
import { CLIIcon } from './CLIIcon'
export interface VersionStatus {
installed: boolean
current?: string
latest?: string
canUpgrade: boolean
}
interface VersionStatusCardProps {
toolId: string
toolName: string
toolDescription?: string
repoUrl?: string
homepage?: string
status: VersionStatus
onInstall?: () => void
onUpgrade?: () => void
onRemove?: () => void
isInstalling?: boolean
isUpgrading?: boolean
}
export const VersionStatusCard: FC<VersionStatusCardProps> = ({
toolId,
toolName,
toolDescription,
repoUrl,
homepage,
status,
onInstall,
onUpgrade,
onRemove,
isInstalling,
isUpgrading
}) => {
const { t } = useTranslation()
return (
<div className="flex flex-col rounded-xl border border-border bg-card p-4 transition-colors duration-200 ease-in-out hover:border-border-hover">
<div className="flex items-start justify-between gap-3">
<div className="flex items-center gap-3">
<div className="flex size-10 shrink-0 items-center justify-center rounded-xl bg-primary/10 text-primary">
<CLIIcon id={toolId} size={20} />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<span className="font-semibold text-foreground text-sm leading-5">{toolName}</span>
</div>
{status.installed && (
<div className="mt-0.5 flex flex-wrap items-center gap-1">
{status.current && (
<Badge variant="secondary" className="gap-1 px-1.5 py-0 text-[11px] leading-4">
v{status.current}
</Badge>
)}
{status.canUpgrade && (
<Badge
variant="outline"
className="gap-1 px-1.5 py-0 text-[11px] leading-4 text-warning border-warning/50">
{t('code.can_upgrade')} v{status.latest}
</Badge>
)}
</div>
)}
{!status.installed && (
<div className="mt-0.5 flex flex-wrap items-center gap-1">
<Badge variant="outline" className="gap-1 px-1.5 py-0 text-[11px] leading-4 text-muted-foreground">
{t('code.not_installed')}
</Badge>
</div>
)}
</div>
</div>
{status.installed && (
<div className="flex shrink-0 items-center gap-1">
{status.canUpgrade && (
<Button
variant="ghost"
size="icon-sm"
className="text-foreground/40 hover:text-foreground"
onClick={onUpgrade}
disabled={isUpgrading}
title={t('code.upgrade')}>
{isUpgrading ? (
<Loader2 className="size-3.5 motion-safe:animate-spin" />
) : (
<RefreshCw className="size-3.5" />
)}
</Button>
)}
<Button
variant="ghost"
size="icon-sm"
className="text-foreground/40 hover:text-destructive"
onClick={onRemove}
disabled={isInstalling || isUpgrading}
aria-label={t('settings.plugins.remove')}
title={t('settings.plugins.remove')}>
<Trash2 className="size-3.5" />
</Button>
</div>
)}
</div>
{toolDescription && (
<p className="mt-2.5 line-clamp-2 text-muted-foreground text-xs leading-4" title={toolDescription}>
{toolDescription}
</p>
)}
<div className="mt-3 flex items-center gap-3">
{repoUrl && (
<button
type="button"
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => void window.api.openWebsite(repoUrl)}>
<ExternalLink className="size-3" />
{repoUrl.replace('https://github.com/', '')}
</button>
)}
{homepage && (
<button
type="button"
className="inline-flex items-center gap-1 text-[11px] text-muted-foreground/70 transition-colors hover:text-foreground"
onClick={() => void window.api.openWebsite(homepage)}>
<ExternalLink className="size-3" />
{homepage.replace(/^https?:\/\//, '')}
</button>
)}
</div>
{!status.installed && (
<div className="mt-3 border-border border-t pt-3">
<Button
variant="outline"
size="sm"
className="h-7 w-full gap-1 font-medium text-xs"
onClick={onInstall}
disabled={isInstalling}
loading={isInstalling}>
{!isInstalling && <Download className="size-3.5" />}
{isInstalling ? t('code.installing') : t('code.install')}
</Button>
</div>
)}
</div>
)
}

View File

@@ -1,23 +1,13 @@
import type { IconComponent } from '@cherrystudio/ui/icons'
import {
ClaudeCode,
GeminiCli,
GithubCopilotCli,
KimiCli,
OpenaiCodex,
OpenCode,
QoderCli,
QwenCode
} from '@cherrystudio/ui/icons'
import { CLAUDE_SUPPORTED_PROVIDERS } from '@renderer/pages/code/codeProviders'
import { ClaudeCode, Nousresearch, OpenaiCodex, Openclaw, OpenCode } from '@cherrystudio/ui/icons'
import { CLAUDE_SUPPORTED_PROVIDERS } from '@renderer/config/codeProviders'
import { formatApiHost } from '@renderer/utils/api'
import { sanitizeProviderName } from '@renderer/utils/naming'
import type { EndpointType } from '@shared/data/types/model'
import type { Provider } from '@shared/data/types/provider'
import { CodeCli } from '@shared/types/codeCli'
import { type CliProviderConfig, codeCLI } from '@shared/types/codeCli'
import {
isAnthropicProvider,
isGeminiProvider,
isNewApiProvider,
isOpenAICompatibleProvider,
isOpenAIProvider
@@ -56,21 +46,58 @@ export interface ToolEnvironmentConfig {
supportsReasoningEffort: boolean
budgetTokens?: number
}
claude?: {
haikuModel?: string
sonnetModel?: string
opusModel?: string
timeoutMs?: string
maxOutputTokens?: string
disableNonessentialTraffic?: number
autoCompactWindow?: string
disableExperimentalBetas?: string
enableToolSearch?: boolean
skipWebFetchPreflight?: boolean
includeCoAuthoredBy?: boolean
effortLevel?: string
enabledPlugins?: Record<string, boolean>
}
codex?: {
reasoningEffort?: string
disableResponseStorage?: boolean
personality?: string
verbosity?: string
contextWindow?: number
autoCompactTokenLimit?: number
reviewModel?: string
}
opencode?: {
contextLimit?: number
outputLimit?: number
}
openclaw?: {
reasoning?: boolean
contextWindow?: number
maxTokens?: number
headers?: Record<string, string>
}
hermes?: {
contextLength?: number
maxTokens?: number
}
}
// CLI 工具选项
// @legacy — removed in v2: qwenCode, geminiCli, qoderCli, kimiCli, githubCopilotCli
export const CLI_TOOLS = [
{ value: CodeCli.CLAUDE_CODE, label: 'Claude Code', icon: ClaudeCode },
{ value: CodeCli.QWEN_CODE, label: 'Qwen Code', icon: QwenCode },
{ value: CodeCli.GEMINI_CLI, label: 'Gemini CLI', icon: GeminiCli },
{ value: CodeCli.OPENAI_CODEX, label: 'OpenAI Codex', icon: OpenaiCodex },
{ value: CodeCli.QODER_CLI, label: 'Qoder CLI', icon: QoderCli },
{ value: CodeCli.GITHUB_COPILOT_CLI, label: 'GitHub Copilot CLI', icon: GithubCopilotCli },
{ value: CodeCli.KIMI_CLI, label: 'Kimi Code', icon: KimiCli },
{ value: CodeCli.OPEN_CODE, label: 'OpenCode', icon: OpenCode }
] as const satisfies ReadonlyArray<{ value: CodeCli; label: string; icon: IconComponent }>
{ value: codeCLI.claudeCode, label: 'Claude Code', icon: ClaudeCode },
{ value: codeCLI.openaiCodex, label: 'OpenAI Codex', icon: OpenaiCodex },
{ value: codeCLI.openCode, label: 'OpenCode', icon: OpenCode },
{ value: codeCLI.openclaw, label: 'OpenClaw', icon: Openclaw },
{ value: codeCLI.hermes, label: 'Hermes', icon: Nousresearch }
] as const satisfies ReadonlyArray<{ value: codeCLI; label: string; icon: IconComponent }>
export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
// @legacy — removed in v2
// export const GEMINI_SUPPORTED_PROVIDERS = ['aihubmix', 'dmxapi', 'new-api', 'cherryin']
export const OPENAI_CODEX_SUPPORTED_PROVIDERS = ['openai', 'openrouter', 'aihubmix', 'new-api', 'cherryin']
@@ -87,24 +114,19 @@ export const CLI_TOOL_PROVIDER_MAP: Record<string, (providers: Provider[]) => Pr
providers.filter(
(p) => isAnthropicProvider(p) || CLAUDE_SUPPORTED_PROVIDERS.includes(p.id) || hasAnthropicEndpoint(p)
),
[CodeCli.GEMINI_CLI]: (providers) =>
providers.filter((p) => isGeminiProvider(p) || GEMINI_SUPPORTED_PROVIDERS.includes(p.id)),
[CodeCli.QWEN_CODE]: (providers) => providers.filter(isOpenAILikeProvider),
[CodeCli.OPENAI_CODEX]: (providers) =>
// @legacy — removed in v2: geminiCli, qwenCode, qoderCli, githubCopilotCli, kimiCli
[codeCLI.openaiCodex]: (providers) =>
providers.filter((p) => isOpenAIProvider(p) || OPENAI_CODEX_SUPPORTED_PROVIDERS.includes(p.id)),
[CodeCli.QODER_CLI]: () => [],
[CodeCli.GITHUB_COPILOT_CLI]: () => [],
[CodeCli.KIMI_CLI]: (providers) => providers.filter(isOpenAILikeProvider),
[CodeCli.OPEN_CODE]: (providers) => providers.filter(isOpenCodeProvider)
[codeCLI.openCode]: (providers) => providers.filter(isOpenCodeProvider),
[codeCLI.openclaw]: (providers) => providers.filter(isOpenCodeProvider),
[codeCLI.hermes]: (providers) => providers.filter(isOpenAILikeProvider)
}
export const getCodeCliApiBaseUrl = (providerId: string, type: 'anthropic' | 'gemini') => {
const CODE_CLI_API_ENDPOINTS = {
aihubmix: {
gemini: {
api_base_url: 'https://aihubmix.com/gemini'
}
},
// @legacy — removed in v2: gemini endpoint type
// export const getCodeCliApiBaseUrl = (providerId, type) => { ... }
export const getCodeCliApiBaseUrl = (providerId: string, type: 'anthropic') => {
const CODE_CLI_API_ENDPOINTS: Record<string, { anthropic?: { api_base_url: string } }> = {
deepseek: {
anthropic: {
api_base_url: 'https://api.deepseek.com/anthropic'
@@ -165,14 +187,8 @@ export const parseEnvironmentVariables = (envVars: string): Record<string, strin
return env
}
/**
* Opencode expects a wire-format string in OPENCODE_PROVIDER_TYPE. v2 has no
* `provider.type`; the caller derives this from v2 predicates.
*/
export type ProviderWireType = 'anthropic' | 'openai-response' | 'openai'
// 为不同 CLI 工具生成环境变量配置
export const generateToolEnvironment = ({
// Resolve the selected provider/model into the typed config the matching CLI writer persists in main
export const generateProviderConfig = ({
tool,
rawModelId,
modelName,
@@ -183,100 +199,110 @@ export const generateToolEnvironment = ({
anthropicBaseUrl,
apiKey,
baseUrl,
reasoning
}: ToolEnvironmentConfig & { providerWireType?: ProviderWireType }): { env: Record<string, string> } => {
const env: Record<string, string> = {}
reasoning,
claude,
codex,
opencode,
openclaw,
hermes
}: ToolEnvironmentConfig): CliProviderConfig => {
const formattedBaseUrl = formatApiHost(baseUrl)
switch (tool) {
case CodeCli.CLAUDE_CODE: {
// https://code.claude.com/docs/en/env-vars — mark provider env as
// host-managed so Claude Code ignores ANTHROPIC_* from the user's
// ~/.claude/settings.json (avoids auth-token/api-key conflict). #15089
env.CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST = '1'
env.ANTHROPIC_BASE_URL = getCodeCliApiBaseUrl(providerId, 'anthropic') || anthropicBaseUrl || baseUrl
env.ANTHROPIC_MODEL = rawModelId
if (isAnthropic) {
env.ANTHROPIC_API_KEY = apiKey
} else {
env.ANTHROPIC_AUTH_TOKEN = apiKey
case codeCLI.claudeCode:
return {
baseUrl: getCodeCliApiBaseUrl(providerId, 'anthropic') || anthropicBaseUrl || baseUrl,
model: rawModelId,
...(isAnthropic ? { apiKey } : { authToken: apiKey }),
...(claude?.haikuModel !== undefined ? { haikuModel: claude.haikuModel } : {}),
...(claude?.sonnetModel !== undefined ? { sonnetModel: claude.sonnetModel } : {}),
...(claude?.opusModel !== undefined ? { opusModel: claude.opusModel } : {}),
...(claude?.timeoutMs !== undefined ? { timeoutMs: claude.timeoutMs } : {}),
...(claude?.maxOutputTokens !== undefined ? { maxOutputTokens: claude.maxOutputTokens } : {}),
...(claude?.disableNonessentialTraffic !== undefined
? { disableNonessentialTraffic: claude.disableNonessentialTraffic }
: {}),
...(claude?.autoCompactWindow !== undefined ? { autoCompactWindow: claude.autoCompactWindow } : {}),
...(claude?.disableExperimentalBetas !== undefined
? { disableExperimentalBetas: claude.disableExperimentalBetas }
: {}),
...(claude?.enableToolSearch !== undefined ? { enableToolSearch: claude.enableToolSearch } : {}),
...(claude?.skipWebFetchPreflight !== undefined ? { skipWebFetchPreflight: claude.skipWebFetchPreflight } : {}),
...(claude?.includeCoAuthoredBy !== undefined ? { includeCoAuthoredBy: claude.includeCoAuthoredBy } : {}),
...(claude?.effortLevel !== undefined ? { effortLevel: claude.effortLevel } : {}),
...(claude?.enabledPlugins !== undefined ? { enabledPlugins: claude.enabledPlugins } : {})
}
// @legacy — removed in v2: geminiCli, qwenCode
case codeCLI.openaiCodex:
return {
apiKey,
baseUrl: formattedBaseUrl,
providerName: sanitizeProviderName(fancyProviderName),
model: rawModelId,
...(codex?.reasoningEffort !== undefined ? { reasoningEffort: codex.reasoningEffort } : {}),
...(codex?.disableResponseStorage !== undefined
? { disableResponseStorage: codex.disableResponseStorage }
: {}),
...(codex?.personality !== undefined ? { personality: codex.personality } : {}),
...(codex?.verbosity !== undefined ? { verbosity: codex.verbosity } : {}),
...(codex?.contextWindow !== undefined ? { contextWindow: codex.contextWindow } : {}),
...(codex?.autoCompactTokenLimit !== undefined ? { autoCompactTokenLimit: codex.autoCompactTokenLimit } : {}),
...(codex?.reviewModel !== undefined ? { reviewModel: codex.reviewModel } : {})
}
// @legacy — removed in v2: kimiCli
case codeCLI.openCode: {
// @ai-sdk/anthropic appends /messages to the baseURL, so preserve any existing /v1 (formatApiHost
// with appendV1=false); other endpoints get the standard /v1.
const isAnthropicEndpoint = endpointType === 'anthropic-messages' || (!endpointType && isAnthropic)
return {
apiKey,
baseUrl: isAnthropicEndpoint ? formatApiHost(baseUrl, false) : formattedBaseUrl,
providerName: sanitizeProviderName(fancyProviderName),
providerType: isAnthropic ? 'anthropic' : 'openai',
endpointType: endpointType ?? '',
model: rawModelId,
modelName,
isReasoning: reasoning?.isReasoning ?? false,
supportsReasoningEffort: reasoning?.supportsReasoningEffort ?? false,
...(reasoning?.budgetTokens !== undefined ? { budgetTokens: reasoning.budgetTokens } : {}),
...(opencode?.contextLimit !== undefined ? { contextLimit: opencode.contextLimit } : {}),
...(opencode?.outputLimit !== undefined ? { outputLimit: opencode.outputLimit } : {})
}
break
}
case CodeCli.GEMINI_CLI: {
const apiBaseUrl = getCodeCliApiBaseUrl(providerId, 'gemini') || baseUrl
env.GEMINI_API_KEY = apiKey
env.GEMINI_BASE_URL = apiBaseUrl
env.GOOGLE_GEMINI_BASE_URL = apiBaseUrl
env.GEMINI_MODEL = rawModelId
break
}
case CodeCli.QWEN_CODE:
env.OPENAI_API_KEY = apiKey
env.OPENAI_BASE_URL = formattedBaseUrl
env.OPENAI_MODEL = rawModelId
break
case CodeCli.OPENAI_CODEX:
// Codex CLI rejects model_providers keys colliding with its reserved
// built-in IDs (openai/ollama/lmstudio). Hand the provider through
// Cherry-namespaced vars; CodeToolsService maps them to a sanitized
// Cherry- prefixed config key (or openai_base_url for reserved). #15068
env.CHERRY_CODEX_API_KEY = apiKey
env.CHERRY_CODEX_BASE_URL = formattedBaseUrl
env.CHERRY_CODEX_PROVIDER_ID = providerId
env.CHERRY_CODEX_PROVIDER_NAME = sanitizeProviderName(fancyProviderName)
break
case CodeCli.QODER_CLI:
env.QODERCN_PERSONAL_ACCESS_TOKEN = apiKey || ''
break
case CodeCli.GITHUB_COPILOT_CLI:
env.GITHUB_TOKEN = apiKey || ''
break
case CodeCli.KIMI_CLI:
env.KIMI_MODEL_NAME = rawModelId
env.KIMI_MODEL_API_KEY = apiKey
env.KIMI_MODEL_BASE_URL = formattedBaseUrl
env.KIMI_MODEL_PROVIDER_TYPE = 'openai'
break
case CodeCli.OPEN_CODE:
// Set environment variable with provider-specific suffix for security
{
// Determine base URL format based on model's endpoint type and provider type
// anthropic: use formatApiHost(url, false) to preserve existing /v1 from provider config
// @ai-sdk/anthropic appends /messages to the baseURL (not /v1/messages)
// others: append /v1 (standard OpenAI-compatible endpoint)
const isAnthropicEndpoint = endpointType === 'anthropic-messages' || (!endpointType && isAnthropic)
const openCodeBaseUrl = isAnthropicEndpoint ? formatApiHost(baseUrl, false) : formattedBaseUrl
env.OPENCODE_BASE_URL = openCodeBaseUrl
env.OPENCODE_MODEL_NAME = modelName
env.OPENCODE_MODEL_ENDPOINT_TYPE = endpointType ?? ''
// Reasoning flags are precomputed by the caller (v2 @shared/utils/model).
const providerName = sanitizeProviderName(fancyProviderName)
env.OPENCODE_MODEL_IS_REASONING = String(reasoning?.isReasoning ?? false)
env.OPENCODE_MODEL_SUPPORTS_REASONING_EFFORT = String(reasoning?.supportsReasoningEffort ?? false)
if (reasoning?.budgetTokens !== undefined) {
env.OPENCODE_MODEL_BUDGET_TOKENS = String(reasoning.budgetTokens)
}
env.OPENCODE_PROVIDER_TYPE = isAnthropic ? 'anthropic' : 'openai'
env.OPENCODE_PROVIDER_NAME = providerName
const envVarKey = `OPENCODE_API_KEY_${providerName.toUpperCase().replace(/[-.]/g, '_')}`
env[envVarKey] = apiKey
// opencode's auto-update check can't detect Cherry Studio's bun install,
// causing a confusing "Update Available" dialog that always fails.
// Cherry Studio manages opencode updates via its own autoUpdateToLatest.
env.OPENCODE_DISABLE_AUTOUPDATE = 'true'
case codeCLI.openclaw:
return {
apiKey,
baseUrl: formattedBaseUrl,
api: isAnthropic ? 'anthropic-messages' : 'openai-completions',
model: rawModelId,
modelName,
providerName: sanitizeProviderName(fancyProviderName),
...(openclaw?.reasoning !== undefined ? { reasoning: openclaw.reasoning } : {}),
...(openclaw?.contextWindow !== undefined ? { contextWindow: openclaw.contextWindow } : {}),
...(openclaw?.maxTokens !== undefined ? { maxTokens: openclaw.maxTokens } : {}),
...(openclaw?.headers !== undefined ? { headers: openclaw.headers } : {})
}
break
case codeCLI.hermes:
return {
apiKey,
baseUrl: formattedBaseUrl,
apiMode: isAnthropic ? 'anthropic_messages' : 'chat_completions',
model: rawModelId,
modelName,
providerName: sanitizeProviderName(fancyProviderName),
...(hermes?.contextLength !== undefined ? { contextLength: hermes.contextLength } : {}),
...(hermes?.maxTokens !== undefined ? { maxTokens: hermes.maxTokens } : {})
}
default:
throw new Error(`Unsupported CLI tool for provider config: ${tool}`)
}
return { env }
}
export { default } from './CodeCliPage'

View File

@@ -1,628 +0,0 @@
import { Alert, Button } from '@cherrystudio/ui'
import { Openclaw } from '@cherrystudio/ui/icons'
import { Navbar, NavbarCenter } from '@renderer/components/app/Navbar'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { CopyIcon } from '@renderer/components/Icons'
import { ModelSelector } from '@renderer/components/Selector'
import { useSharedCache } from '@renderer/data/hooks/useCache'
import { usePreference } from '@renderer/data/hooks/usePreference'
import { useMiniAppPopup } from '@renderer/hooks/useMiniAppPopup'
import { useModelById } from '@renderer/hooks/useModel'
import { useProviders } from '@renderer/hooks/useProvider'
import { loggerService } from '@renderer/services/LoggerService'
import { type Model as SharedModel } from '@shared/data/types/model'
import { isUniqueModelId, type UniqueModelId } from '@shared/data/types/model'
import { IpcChannel } from '@shared/IpcChannel'
import { isNonChatModel } from '@shared/utils/model'
import { ChevronDown, Download, ExternalLink, Loader2, Play, Square, X } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import useSWR, { mutate } from 'swr'
import UpdateButton from './components/UpdateButton'
const logger = loggerService.withContext('OpenClawPage')
const DEFAULT_DOCS_URL = 'https://docs.openclaw.ai/'
const NO_API_KEY_PROVIDERS = new Set(['ollama', 'lmstudio', 'gpustack'])
interface TitleSectionProps {
title: string
description: string
clickable?: boolean
docsUrl?: string
}
const TitleSection: FC<TitleSectionProps> = ({ title, description, clickable = false, docsUrl }) => (
<div className="-mt-20 mb-8 flex flex-col items-center text-center">
<div
className={clickable ? 'cursor-pointer' : undefined}
onClick={clickable ? () => window.open(docsUrl ?? DEFAULT_DOCS_URL, '_blank') : undefined}>
<Openclaw.Avatar size={64} shape="rounded" />
</div>
<h1
className={`mt-3 font-semibold text-2xl ${clickable ? 'cursor-pointer hover:text-(--color-primary)' : ''}`}
style={{ color: 'var(--color-text-1)' }}
onClick={clickable ? () => window.open(docsUrl ?? DEFAULT_DOCS_URL, '_blank') : undefined}>
{title}
</h1>
<p className="mt-3 text-sm leading-relaxed" style={{ color: 'var(--color-text-2)' }}>
{description}
</p>
</div>
)
const OpenClawPage: FC = () => {
const { t, i18n } = useTranslation()
const { providers } = useProviders()
const { openSmartMiniApp } = useMiniAppPopup()
const docsUrl = useMemo(() => {
const lang = i18n.language?.toLowerCase() ?? ''
if (lang.startsWith('zh-cn')) {
return 'https://docs.openclaw.ai/zh-CN'
}
return DEFAULT_DOCS_URL
}, [i18n.language])
const [gatewayPort] = usePreference('feature.openclaw.gateway_port')
const [selectedModelId, setSelectedModelId] = usePreference('feature.openclaw.selected_model_id')
const [gatewayStatus, setGatewayStatus] = useSharedCache('feature.openclaw.gateway_status')
const [error, setError] = useState<string | null>(null)
const [isInstalled, setIsInstalled] = useState<boolean | null>(null) // null = unknown, checking in background
const [needsMigration, setNeedsMigration] = useState(false)
const [installPath, setInstallPath] = useState<string | null>(null)
const [installError, setInstallError] = useState<string | null>(null)
// Separate loading states for each action
const [isInstalling, setIsInstalling] = useState(false)
const [isUninstalling, setIsUninstalling] = useState(false)
const [isStarting, setIsStarting] = useState(false)
const [isStopping, setIsStopping] = useState(false)
// Install progress logs
const [installLogs, setInstallLogs] = useState<Array<{ message: string; type: 'info' | 'warn' | 'error' }>>([])
const [showLogs, setShowLogs] = useState(false)
const [uninstallSuccess, setUninstallSuccess] = useState(false)
const [isOpenClawUpdating, setIsOpenClawUpdating] = useState(false)
const selectedUniqueModelId = useMemo<UniqueModelId | undefined>(() => {
return selectedModelId && isUniqueModelId(selectedModelId) ? selectedModelId : undefined
}, [selectedModelId])
/** v2 read-only lookup for the currently-selected model — drives the trigger label.
* The hook short-circuits on falsy ids via `enabled: !!uniqueModelId`, so the
* empty-string fallback is safe; the cast satisfies its template-literal arg type. */
const { model: selectedModel } = useModelById((selectedUniqueModelId ?? '') as UniqueModelId)
/**
* Drop models whose owning provider has no usable credentials. Local
* runtimes (ollama / lmstudio / gpustack) bypass the check since they
* accept any placeholder. The new ModelSelector already filters out
* disabled providers, so we only need the credential check here.
*/
const modelFilter = useCallback(
(model: SharedModel) => {
if (isNonChatModel(model)) return false
const provider = providers.find((p) => p.id === model.providerId)
if (!provider) return false
if (NO_API_KEY_PROVIDERS.has(provider.id)) return true
return provider.apiKeys.some((k) => k.isEnabled)
},
[providers]
)
type PageState = 'checking' | 'not_installed' | 'installed' | 'installing' | 'uninstalling'
const pageState: PageState = useMemo(() => {
if (isUninstalling) return 'uninstalling'
if (isInstalling) return 'installing'
if (isInstalled === null) return 'checking'
if (isInstalled) return 'installed'
return 'not_installed'
}, [isInstalled, isInstalling, isUninstalling])
const checkInstallation = useCallback(async () => {
try {
const result = await window.api.openclaw.checkInstalled()
setIsInstalled(result.installed)
setNeedsMigration(result.needsMigration)
setShowLogs(false)
setInstallPath(result.path)
} catch (err) {
logger.debug('Failed to check installation', err as Error)
setIsInstalled(false)
}
}, [])
const handleInstall = useCallback(async () => {
setIsInstalling(true)
setInstallError(null)
setInstallLogs([])
setShowLogs(true)
try {
const result = await window.api.openclaw.install()
if (result.success) {
await checkInstallation()
} else {
setInstallError(result.message)
}
} catch (err) {
logger.error('Failed to install OpenClaw', err as Error)
setInstallError(err instanceof Error ? err.message : String(err))
} finally {
setIsInstalling(false)
}
}, [checkInstallation])
const handleUninstall = useCallback(async () => {
// Use window.confirm for confirmation
const confirmed = window.confirm(t('openclaw.uninstall_confirm'))
if (!confirmed) {
return // User cancelled
}
setIsUninstalling(true)
setUninstallSuccess(false)
setInstallError(null)
setInstallLogs([])
setShowLogs(true)
try {
const result = await window.api.openclaw.uninstall()
if (result.success) {
setUninstallSuccess(true)
} else {
setInstallError(result.message)
setIsUninstalling(false)
}
} catch (err) {
logger.error('Failed to uninstall OpenClaw', err as Error)
setInstallError(err instanceof Error ? err.message : String(err))
setIsUninstalling(false)
}
}, [t])
const handleUninstallComplete = useCallback(() => {
setShowLogs(false)
setIsUninstalling(false)
if (uninstallSuccess) {
setIsInstalled(false)
setUninstallSuccess(false)
}
}, [uninstallSuccess])
// Poll gateway status every 5s (only when installed).
// useSWR handles deduplication and stable polling without the infinite-loop
// pitfall of setInterval + dispatch in useEffect deps.
const isInstallPage = pageState === 'installed'
useSWR(
isInstallPage ? 'openclaw/status' : null,
async () => {
const [status] = await Promise.all([window.api.openclaw.getStatus(), checkInstallation()])
setGatewayStatus(status.status)
return status
},
{ refreshInterval: 5000, revalidateOnFocus: false }
)
useEffect(() => {
void checkInstallation()
}, [checkInstallation])
// Listen for install progress events
useEffect(() => {
const cleanup = window.electron.ipcRenderer.on(
IpcChannel.OpenClaw_InstallProgress,
(_, data: { message: string; type: 'info' | 'warn' | 'error' }) => {
setInstallLogs((prev) => [...prev, data])
}
)
return cleanup
}, [])
const handleModelSelect = (next: UniqueModelId | undefined) => {
if (!next) return
void setSelectedModelId(next)
}
const handleStartGateway = async () => {
if (!selectedUniqueModelId) {
setError(t('openclaw.error.select_provider_model'))
return
}
setIsStarting(true)
setError(null)
try {
// First sync the configuration (auth token will be auto-generated in main process)
const syncResult = await window.api.openclaw.syncConfig(selectedUniqueModelId)
if (!syncResult.success) {
setError(syncResult.message)
setIsStarting(false)
return
}
// Then start the gateway
const startResult = await window.api.openclaw.startGateway(gatewayPort)
if (!startResult.success) {
setError(startResult.message)
setIsStarting(false)
return
}
// Auto open dashboard first
const dashboardUrl = await window.api.openclaw.getDashboardUrl()
openSmartMiniApp({
appId: 'openclaw-dashboard',
name: 'OpenClaw',
url: dashboardUrl,
logo: 'openclaw'
})
void mutate('openclaw/status', { status: 'running' }, { revalidate: false })
setGatewayStatus('running')
setIsStarting(false)
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
setIsStarting(false)
}
}
const handleStopGateway = async () => {
setIsStopping(true)
try {
const result = await window.api.openclaw.stopGateway()
if (result.success) {
setGatewayStatus('stopped')
} else {
setError(result.message)
}
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setIsStopping(false)
}
}
const handleOpenDashboard = async () => {
try {
const dashboardUrl = await window.api.openclaw.getDashboardUrl()
openSmartMiniApp({
appId: 'openclaw-dashboard',
name: 'OpenClaw',
url: dashboardUrl,
logo: 'openclaw'
})
} catch (err) {
logger.error('Failed to open dashboard', err as Error)
setError(err instanceof Error ? err.message : String(err))
}
}
const renderLogContainer = (expanded = false) => (
<div className="mb-6 overflow-hidden rounded-lg" style={{ background: 'var(--color-background-soft)' }}>
<div
className="flex items-center justify-between px-3 py-2 font-medium text-[13px]"
style={{ background: 'var(--color-background-mute)' }}>
<span>{t(expanded ? 'openclaw.uninstall_progress' : 'openclaw.install_progress')}</span>
{!expanded && (
<Button size="sm" variant="ghost" onClick={() => setShowLogs(false)}>
{t('common.close')}
</Button>
)}
</div>
<div className={`overflow-y-auto px-3 py-2 font-mono text-xs leading-relaxed ${expanded ? 'h-75' : 'h-37.5'}`}>
{installLogs.map((log, index) => (
<div
key={index}
className="whitespace-pre-wrap break-all"
style={{
color:
log.type === 'error'
? 'var(--color-error)'
: log.type === 'warn'
? 'var(--color-warning)'
: 'var(--color-text-2)'
}}>
{log.message}
</div>
))}
</div>
</div>
)
const renderNotInstalledContent = () => (
<div id="content-container" className="flex flex-1 flex-col overflow-y-auto py-5">
<div className="flex-1" />
<div className="mx-auto min-h-fit w-130 shrink-0">
<div className="mb-6 flex flex-col items-center text-center">
<Openclaw.Avatar size={64} shape="rounded" />
<h2 className="mt-5 font-semibold text-foreground text-lg">
{t(needsMigration ? 'openclaw.migration.title' : 'openclaw.not_installed.title')}
</h2>
<p className="mt-2 text-muted-foreground text-sm leading-relaxed">
{t(needsMigration ? 'openclaw.migration.description' : 'openclaw.not_installed.description')}
</p>
<div className="mt-6 flex items-center justify-center gap-2">
<Button disabled={isInstalling} onClick={handleInstall} loading={isInstalling}>
{!isInstalling && <Download size={16} />}
{t(needsMigration ? 'openclaw.migration.install_button' : 'openclaw.not_installed.install_button')}
</Button>
<Button variant="outline" disabled={isInstalling} onClick={() => window.open(docsUrl, '_blank')}>
<ExternalLink size={16} />
{t('openclaw.quick_actions.view_docs')}
</Button>
</div>
</div>
{installError && (
<Alert
message={installError}
type="error"
className="mb-4"
action={
<Button
size="icon-sm"
variant="ghost"
aria-label={t('common.close')}
onClick={() => setInstallError(null)}>
<X size={14} />
</Button>
}
/>
)}
{showLogs && installLogs.length > 0 && renderLogContainer()}
</div>
<div className="flex-1" />
</div>
)
const renderInstalledContent = () => (
<div id="content-container" className="flex flex-1 overflow-y-auto py-5">
<div className="m-auto min-h-fit w-130">
<TitleSection title={t('openclaw.title')} description={t('openclaw.description')} clickable docsUrl={docsUrl} />
{/* Install Path - hide when gateway is running */}
{installPath && gatewayStatus !== 'running' && (
<div
className="mb-6 flex items-center justify-between gap-2 rounded-lg px-3 py-2 text-sm"
style={{ background: 'var(--color-background-soft)', color: 'var(--color-text-3)' }}>
<div className="min-w-0 shrink overflow-hidden">
<div className="mb-1">{t('openclaw.installed_at')}</div>
<div className="flex items-center gap-2">
<div className="truncate text-xs" title={installPath}>
{installPath}
</div>
<Button
size="icon-sm"
variant="ghost"
className="size-4! p-0! shadow-none"
aria-label={t('common.copy')}
onClick={async () => {
try {
await navigator.clipboard.writeText(installPath)
window.toast.success(t('common.copied'))
} catch (error) {
window.toast.error(t('common.copy_failed'))
logger.error('Failed to copy install path:', error as Error)
}
}}>
<CopyIcon className="size-3!" />
</Button>
<UpdateButton onUpdateComplete={checkInstallation} onUpdatingChange={setIsOpenClawUpdating} />
</div>
</div>
<span
className="cursor-pointer whitespace-nowrap text-xs transition-colors hover:text-(--color-error)!"
style={{ color: 'var(--color-text-3)' }}
onClick={handleUninstall}>
{t('openclaw.quick_actions.uninstall')}
</span>
</div>
)}
{/* Gateway Status Card - show when running */}
{gatewayStatus === 'running' && (
<div
className="mb-6 flex items-center justify-between rounded-lg p-3"
style={{ background: 'var(--color-background-soft)' }}>
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-green-500" />
<span className="font-medium text-sm" style={{ color: 'var(--color-text-1)' }}>
{t('openclaw.status.running')}
</span>
<span className="font-mono text-[13px]" style={{ color: 'var(--color-text-3)' }}>
:{gatewayPort}
</span>
</div>
<Button
size="sm"
variant="ghost"
onClick={handleStopGateway}
loading={isStopping}
disabled={isStopping}
className="text-destructive hover:text-destructive">
{!isStopping && <Square size={14} />}
{t('openclaw.gateway.stop')}
</Button>
</div>
)}
{/* Error Alert */}
{error && (
<div className="mb-6">
<Alert
message={
<div className="flex items-start justify-between gap-2">
<span className="max-h-25 flex-1 overflow-y-auto whitespace-pre-wrap break-all">{error}</span>
</div>
}
type="error"
action={
<div className="flex items-center gap-1">
<Button
size="icon-sm"
variant="ghost"
className="shadow-none"
aria-label={t('common.copy')}
onClick={async () => {
try {
await navigator.clipboard.writeText(error)
window.toast.success(t('common.copied'))
} catch (err) {
window.toast.error(t('common.copy_failed'))
logger.error('Failed to copy error message:', err as Error)
}
}}>
<CopyIcon className="size-3!" />
</Button>
<Button
size="icon-sm"
variant="ghost"
className="shadow-none"
aria-label={t('common.close')}
onClick={() => setError(null)}>
<X size={14} />
</Button>
</div>
}
className="rounded-lg"
/>
</div>
)}
{/* Model Selector - only show when not running */}
{gatewayStatus !== 'running' && (
<div className="mb-6">
<div className="mb-2 flex items-center gap-2 font-medium text-sm" style={{ color: 'var(--color-text-1)' }}>
{t('openclaw.model_config.model')}
</div>
<ModelSelector
multiple={false}
selectionType="id"
value={selectedUniqueModelId}
filter={modelFilter}
onSelect={handleModelSelect}
trigger={
<Button variant="outline" className="w-full justify-start">
{selectedModel ? <ModelAvatar model={selectedModel} size={18} /> : null}
<span className="flex-1 truncate text-left">
{selectedModel ? selectedModel.name : t('openclaw.model_config.select_model')}
</span>
<ChevronDown size={14} className="text-muted-foreground" />
</Button>
}
/>
<div className="mt-1 text-xs" style={{ color: 'var(--color-text-3)' }}>
{t('openclaw.model_config.sync_hint')}
</div>
{/* Tips about OpenClaw */}
<div
className="mt-4 rounded-lg p-3 text-xs leading-relaxed"
style={{ background: 'var(--color-background-mute)', color: 'var(--color-text-3)' }}>
<div className="mb-1">💡 {t('openclaw.tips.title')}</div>
<ul className="list-inside list-disc space-y-1">
<li>{t('openclaw.tips.permissions')}</li>
<li>{t('openclaw.tips.token_usage')}</li>
</ul>
</div>
</div>
)}
{showLogs && installLogs.length > 0 && renderLogContainer()}
{gatewayStatus !== 'running' && (
<Button
onClick={handleStartGateway}
loading={isStarting || gatewayStatus === 'starting'}
disabled={!selectedUniqueModelId || isStarting || gatewayStatus === 'starting' || isOpenClawUpdating}
size="lg"
className="w-full">
{!isStarting && gatewayStatus !== 'starting' && <Play size={16} />}
{t('openclaw.gateway.start')}
</Button>
)}
{gatewayStatus === 'running' && (
<Button onClick={handleOpenDashboard} size="lg" className="w-full">
{t('openclaw.quick_actions.open_dashboard')}
</Button>
)}
</div>
</div>
)
const renderCheckingContent = () => (
<div id="content-container" className="flex flex-1 flex-col items-center justify-center">
<Loader2 className="size-7 animate-spin" style={{ color: 'var(--color-primary)' }} />
<div className="mt-4" style={{ color: 'var(--color-text-3)' }}>
{t('openclaw.checking_installation')}
</div>
</div>
)
// Render uninstalling page - only show logs
const renderUninstallingContent = () => (
<div id="content-container" className="flex flex-1 overflow-y-auto py-5">
<div className="m-auto min-h-fit w-130">
<TitleSection
title={t(uninstallSuccess ? 'openclaw.uninstalled.title' : 'openclaw.uninstalling.title')}
description={t(uninstallSuccess ? 'openclaw.uninstalled.description' : 'openclaw.uninstalling.description')}
/>
{installError && (
<div className="mb-6">
<Alert
message={installError}
type="error"
action={
<Button
size="icon-sm"
variant="ghost"
aria-label={t('common.close')}
onClick={() => setInstallError(null)}>
<X size={14} />
</Button>
}
className="rounded-lg"
/>
</div>
)}
{renderLogContainer(true)}
<Button disabled={!uninstallSuccess} onClick={handleUninstallComplete} className="w-full" size="lg">
{t('common.close')}
</Button>
</div>
</div>
)
const renderContent = () => {
switch (pageState) {
case 'uninstalling':
return renderUninstallingContent()
case 'checking':
return renderCheckingContent()
case 'installed':
return renderInstalledContent()
case 'not_installed':
case 'installing':
return renderNotInstalledContent()
}
}
return (
<div className="flex flex-1 flex-col">
<Navbar>
<NavbarCenter style={{ borderRight: 'none' }}>{t('openclaw.title')}</NavbarCenter>
</Navbar>
<div className="flex flex-1 flex-col">{renderContent()}</div>
</div>
)
}
export default OpenClawPage

View File

@@ -1,103 +0,0 @@
import { loggerService } from '@renderer/services/LoggerService'
import { ArrowUpCircle, Loader2 } from 'lucide-react'
import type { FC } from 'react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
const logger = loggerService.withContext('UpdateButton')
export interface UpdateInfo {
hasUpdate: boolean
currentVersion: string | null
latestVersion: string | null
message?: string
}
interface UpdateButtonProps {
onUpdateComplete?: () => void
onUpdatingChange?: (isUpdating: boolean) => void
}
const UpdateButton: FC<UpdateButtonProps> = ({ onUpdateComplete, onUpdatingChange }) => {
const { t } = useTranslation()
const [updateInfo, setUpdateInfo] = useState<UpdateInfo | null>(null)
const [isUpdating, setIsUpdating] = useState(false)
// Notify parent when updating state changes
useEffect(() => {
onUpdatingChange?.(isUpdating)
}, [isUpdating, onUpdatingChange])
const checkUpdate = useCallback(async () => {
try {
const result = await window.api.openclaw.checkUpdate()
setUpdateInfo(result)
} catch (err) {
logger.error('Failed to check for updates', err as Error)
}
}, [])
const performUpdate = useCallback(async () => {
setIsUpdating(true)
try {
const result = await window.api.openclaw.performUpdate()
if (result.success) {
setUpdateInfo(null)
window.toast.success(t('openclaw.update.success'))
onUpdateComplete?.()
} else {
window.toast.error(result.message)
}
} catch (err) {
logger.error('Failed to update OpenClaw', err as Error)
window.toast.error(err instanceof Error ? err.message : String(err))
} finally {
setIsUpdating(false)
}
}, [onUpdateComplete, t])
// Check for updates on mount
useEffect(() => {
void checkUpdate()
}, [checkUpdate])
const handleClick = () => {
if (isUpdating) return
window.modal.confirm({
title: t('openclaw.update.modal_title'),
content: t('openclaw.update.available', {
latest: updateInfo?.latestVersion,
current: updateInfo?.currentVersion
}),
okText: t('openclaw.update.confirm_button'),
cancelText: t('common.cancel'),
centered: true,
onOk: () => {
// Start update without waiting, modal closes immediately
void performUpdate()
}
})
}
// Don't render if no update available and not updating
if (!updateInfo?.hasUpdate && !isUpdating) {
return null
}
return (
<span className="inline-flex cursor-pointer items-center gap-1" onClick={handleClick}>
{isUpdating ? (
<Loader2 className="size-3! animate-spin" style={{ color: 'var(--color-primary)' }} />
) : (
<ArrowUpCircle className="size-3!" color="var(--color-primary)" />
)}
<span className="text-xs" style={{ color: 'var(--color-primary)' }}>
{isUpdating ? t('openclaw.update.updating') : `v${updateInfo?.latestVersion}`}
</span>
</span>
)
}
export default UpdateButton

View File

@@ -29,7 +29,6 @@ import { Route as SettingsChannelsRouteImport } from './routes/settings/channels
import { Route as SettingsApiGatewayRouteImport } from './routes/settings/api-gateway'
import { Route as SettingsAboutRouteImport } from './routes/settings/about'
import { Route as AppTranslateRouteImport } from './routes/app/translate'
import { Route as AppOpenclawRouteImport } from './routes/app/openclaw'
import { Route as AppNotesRouteImport } from './routes/app/notes'
import { Route as AppLibraryRouteImport } from './routes/app/library'
import { Route as AppLaunchpadRouteImport } from './routes/app/launchpad'
@@ -152,11 +151,6 @@ const AppTranslateRoute = AppTranslateRouteImport.update({
path: '/translate',
getParentRoute: () => AppRoute,
} as any)
const AppOpenclawRoute = AppOpenclawRouteImport.update({
id: '/openclaw',
path: '/openclaw',
getParentRoute: () => AppRoute,
} as any)
const AppNotesRoute = AppNotesRouteImport.update({
id: '/notes',
path: '/notes',
@@ -270,7 +264,6 @@ export interface FileRoutesByFullPath {
'/app/launchpad': typeof AppLaunchpadRoute
'/app/library': typeof AppLibraryRoute
'/app/notes': typeof AppNotesRoute
'/app/openclaw': typeof AppOpenclawRoute
'/app/translate': typeof AppTranslateRoute
'/settings/about': typeof SettingsAboutRoute
'/settings/api-gateway': typeof SettingsApiGatewayRoute
@@ -312,7 +305,6 @@ export interface FileRoutesByTo {
'/app/launchpad': typeof AppLaunchpadRoute
'/app/library': typeof AppLibraryRoute
'/app/notes': typeof AppNotesRoute
'/app/openclaw': typeof AppOpenclawRoute
'/app/translate': typeof AppTranslateRoute
'/settings/about': typeof SettingsAboutRoute
'/settings/api-gateway': typeof SettingsApiGatewayRoute
@@ -355,7 +347,6 @@ export interface FileRoutesById {
'/app/launchpad': typeof AppLaunchpadRoute
'/app/library': typeof AppLibraryRoute
'/app/notes': typeof AppNotesRoute
'/app/openclaw': typeof AppOpenclawRoute
'/app/translate': typeof AppTranslateRoute
'/settings/about': typeof SettingsAboutRoute
'/settings/api-gateway': typeof SettingsApiGatewayRoute
@@ -400,7 +391,6 @@ export interface FileRouteTypes {
| '/app/launchpad'
| '/app/library'
| '/app/notes'
| '/app/openclaw'
| '/app/translate'
| '/settings/about'
| '/settings/api-gateway'
@@ -442,7 +432,6 @@ export interface FileRouteTypes {
| '/app/launchpad'
| '/app/library'
| '/app/notes'
| '/app/openclaw'
| '/app/translate'
| '/settings/about'
| '/settings/api-gateway'
@@ -484,7 +473,6 @@ export interface FileRouteTypes {
| '/app/launchpad'
| '/app/library'
| '/app/notes'
| '/app/openclaw'
| '/app/translate'
| '/settings/about'
| '/settings/api-gateway'
@@ -664,13 +652,6 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AppTranslateRouteImport
parentRoute: typeof AppRoute
}
'/app/openclaw': {
id: '/app/openclaw'
path: '/openclaw'
fullPath: '/app/openclaw'
preLoaderRoute: typeof AppOpenclawRouteImport
parentRoute: typeof AppRoute
}
'/app/notes': {
id: '/app/notes'
path: '/notes'
@@ -823,7 +804,6 @@ interface AppRouteChildren {
AppLaunchpadRoute: typeof AppLaunchpadRoute
AppLibraryRoute: typeof AppLibraryRoute
AppNotesRoute: typeof AppNotesRoute
AppOpenclawRoute: typeof AppOpenclawRoute
AppTranslateRoute: typeof AppTranslateRoute
AppMiniAppAppIdRoute: typeof AppMiniAppAppIdRoute
AppPaintingsSplatRoute: typeof AppPaintingsSplatRoute
@@ -840,7 +820,6 @@ const AppRouteChildren: AppRouteChildren = {
AppLaunchpadRoute: AppLaunchpadRoute,
AppLibraryRoute: AppLibraryRoute,
AppNotesRoute: AppNotesRoute,
AppOpenclawRoute: AppOpenclawRoute,
AppTranslateRoute: AppTranslateRoute,
AppMiniAppAppIdRoute: AppMiniAppAppIdRoute,
AppPaintingsSplatRoute: AppPaintingsSplatRoute,

View File

@@ -1,6 +0,0 @@
import OpenClawPage from '@renderer/pages/openclaw/OpenClawPage'
import { createFileRoute } from '@tanstack/react-router'
export const Route = createFileRoute('/app/openclaw')({
component: OpenClawPage
})

View File

@@ -18,7 +18,6 @@ const routeTitleKeys: Record<string, string> = {
'/app/files': 'title.files',
'/app/code': 'title.code',
'/app/notes': 'title.notes',
'/app/openclaw': 'title.openclaw',
'/settings': 'title.settings'
}

View File

@@ -1,3 +1,4 @@
import type { SidebarMenuItem } from '@renderer/components/Sidebar/types'
import {
buildTabInstanceMetadata,
getTabInstanceAppId,
@@ -118,10 +119,6 @@ export const SIDEBAR_APPS: readonly SidebarApp[] = [
{
id: 'notes',
routePrefix: '/app/notes'
},
{
id: 'openclaw',
routePrefix: '/app/openclaw'
}
]