fix: support desktop output style config (#652)

Fixes #652.

Add desktop/server output style settings APIs and a General Settings picker
that mirrors the Claude CLI outputStyle sources.

Persist active-project choices to .claude/settings.local.json and global
choices to user settings, and route /config to the local settings UI.

Tested:
- bun test src/server/__tests__/settings.test.ts
- cd desktop && bun run test -- src/components/chat/composerUtils.test.ts src/stores/settingsStoreOutputStyle.test.ts src/pages/SettingsOutputStyle.test.tsx
- cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx
- bun run check:desktop
- bun run check:server
- bun run verify

Confidence: high
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-10 15:24:20 +08:00
parent e8bf789961
commit 56661c7967
17 changed files with 877 additions and 4 deletions

View File

@ -218,6 +218,23 @@ describe('Settings > General tab', () => {
},
h5AccessDiagnostics: null,
h5AccessError: null,
outputStyle: 'default',
outputStyles: [
{
value: 'default',
label: 'Default',
description: 'Default response style',
source: 'built-in',
},
],
outputStyleScope: 'userSettings',
outputStyleWorkDir: null,
outputStylesLoading: false,
outputStyleError: null,
fetchOutputStyles: vi.fn().mockResolvedValue(undefined),
setOutputStyle: vi.fn().mockImplementation(async (outputStyle: string) => {
useSettingsStore.setState({ outputStyle })
}),
setThinkingEnabled: vi.fn().mockImplementation(async (enabled: boolean) => {
useSettingsStore.setState({ thinkingEnabled: enabled })
}),

View File

@ -1,5 +1,5 @@
import { api } from './client'
import type { PermissionMode, UserSettings } from '../types/settings'
import type { OutputStylesResponse, PermissionMode, UserSettings } from '../types/settings'
export type CliLauncherStatus = {
supported: boolean
@ -24,6 +24,23 @@ export const settingsApi = {
return api.put<{ ok: true }>('/api/settings/user', settings)
},
getOutputStyles(workDir?: string | null) {
const query = workDir ? `?workDir=${encodeURIComponent(workDir)}` : ''
return api.get<OutputStylesResponse>(`/api/settings/output-styles${query}`)
},
setOutputStyle(outputStyle: string, workDir?: string | null) {
return api.put<{
ok: true
outputStyle: string
scope: OutputStylesResponse['scope']
workDir: string | null
}>('/api/settings/output-style', {
outputStyle,
...(workDir ? { workDir } : {}),
})
},
getPermissionMode() {
return api.get<{ mode: PermissionMode }>('/api/permissions/mode')
},

View File

@ -165,9 +165,13 @@ describe('composerUtils', () => {
expect(resolveSlashUiAction('plugins')).toEqual({ type: 'settings', tab: 'plugins' })
expect(resolveSlashUiAction('memory')).toEqual({ type: 'settings', tab: 'memory' })
expect(resolveSlashUiAction('doctor')).toEqual({ type: 'settings', tab: 'diagnostics' })
expect(resolveSlashUiAction('config')).toEqual({ type: 'settings', tab: 'general' })
expect(resolveSlashUiAction('settings')).toEqual({ type: 'settings', tab: 'general' })
expect(mergeSlashCommands([]).map((command) => command.name)).toContain('plugin')
expect(mergeSlashCommands([]).map((command) => command.name)).toContain('memory')
expect(mergeSlashCommands([]).map((command) => command.name)).toContain('config')
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('plugins')
expect(mergeSlashCommands([]).map((command) => command.name)).not.toContain('settings')
})
it('routes session inspection commands to the desktop panel', () => {

View File

@ -43,6 +43,7 @@ export const PANEL_SLASH_COMMANDS = [
] as const
export const SETTINGS_SLASH_COMMANDS = [
{ name: 'config', tab: 'general' as const },
{ name: 'plugin', tab: 'plugins' as const },
{ name: 'memory', tab: 'memory' as const },
{ name: 'doctor', tab: 'diagnostics' as const },
@ -50,6 +51,7 @@ export const SETTINGS_SLASH_COMMANDS = [
export const SLASH_COMMAND_ALIASES = [
{ name: 'plugins', target: 'plugin' },
{ name: 'settings', target: 'config' },
] as const
/** Static fallback with English descriptions (for non-React contexts) */

View File

@ -929,6 +929,22 @@ export const en = {
'settings.general.responseLangTitle': 'Response Language',
'settings.general.responseLangDescription': 'Instruct Claude to always respond in a specific language.',
'settings.general.responseLangDefault': 'Default (English)',
'settings.general.outputStyleTitle': 'Output Style',
'settings.general.outputStyleDescription': 'Choose how Claude communicates in new or restarted sessions.',
'settings.general.outputStyleSelectLabel': 'Select output style',
'settings.general.outputStyleLoading': 'Loading output styles...',
'settings.general.outputStyleSaved': 'Output style saved.',
'settings.general.outputStyleRestartHint': 'Running sessions keep their current prompt. Start a new session, continue in a fresh process, or restart the session for this to apply.',
'settings.general.outputStyleScopeLocal': 'Current project',
'settings.general.outputStyleScopeUser': 'User settings',
'settings.general.outputStyleScopeLocalHint': 'Saved to .claude/settings.local.json for the active project.',
'settings.general.outputStyleScopeUserHint': 'Saved to your user settings because no active project is selected.',
'settings.general.outputStyleSourceBuiltIn': 'Built-in',
'settings.general.outputStyleSourceUser': 'User style',
'settings.general.outputStyleSourceProject': 'Project style',
'settings.general.outputStyleSourceLocal': 'Local project style',
'settings.general.outputStyleSourcePolicy': 'Managed style',
'settings.general.outputStyleSourcePlugin': 'Plugin style',
'settings.general.effortTitle': 'Effort Level',
'settings.general.effortDescription': 'Controls how much computation the model uses.',
'settings.general.effort.low': 'Low',

View File

@ -931,6 +931,22 @@ export const jp: Record<TranslationKey, string> = {
'settings.general.responseLangTitle': '応答言語',
'settings.general.responseLangDescription': 'Claude が常に特定の言語で応答するよう指示します。',
'settings.general.responseLangDefault': 'デフォルト(英語)',
'settings.general.outputStyleTitle': '出力スタイル',
'settings.general.outputStyleDescription': '新しいセッションまたは再起動後のセッションで Claude がどのように応答するかを選択します。',
'settings.general.outputStyleSelectLabel': '出力スタイルを選択',
'settings.general.outputStyleLoading': '出力スタイルを読み込み中...',
'settings.general.outputStyleSaved': '出力スタイルを保存しました。',
'settings.general.outputStyleRestartHint': '実行中のセッションは現在のプロンプトを使い続けます。新しいセッションを開始するか、新しいプロセスで続行するか、セッションを再起動すると適用されます。',
'settings.general.outputStyleScopeLocal': '現在のプロジェクト',
'settings.general.outputStyleScopeUser': 'ユーザー設定',
'settings.general.outputStyleScopeLocalHint': 'アクティブなプロジェクトの .claude/settings.local.json に保存されます。',
'settings.general.outputStyleScopeUserHint': 'アクティブなプロジェクトが選択されていないため、ユーザー設定に保存されます。',
'settings.general.outputStyleSourceBuiltIn': '組み込み',
'settings.general.outputStyleSourceUser': 'ユーザースタイル',
'settings.general.outputStyleSourceProject': 'プロジェクトスタイル',
'settings.general.outputStyleSourceLocal': 'ローカルプロジェクトスタイル',
'settings.general.outputStyleSourcePolicy': '管理スタイル',
'settings.general.outputStyleSourcePlugin': 'プラグインスタイル',
'settings.general.effortTitle': '労力レベル',
'settings.general.effortDescription': 'モデルが使用する計算量を制御します。',
'settings.general.effort.low': '低',

View File

@ -931,6 +931,22 @@ export const kr: Record<TranslationKey, string> = {
'settings.general.responseLangTitle': '응답 언어',
'settings.general.responseLangDescription': 'Claude가 항상 특정 언어로 응답하도록 지시합니다.',
'settings.general.responseLangDefault': '기본값(영어)',
'settings.general.outputStyleTitle': '출력 스타일',
'settings.general.outputStyleDescription': '새 세션 또는 다시 시작한 세션에서 Claude가 응답하는 방식을 선택합니다.',
'settings.general.outputStyleSelectLabel': '출력 스타일 선택',
'settings.general.outputStyleLoading': '출력 스타일 불러오는 중...',
'settings.general.outputStyleSaved': '출력 스타일이 저장되었습니다.',
'settings.general.outputStyleRestartHint': '실행 중인 세션은 현재 프롬프트를 계속 사용합니다. 새 세션을 시작하거나 새 프로세스에서 계속하거나 세션을 다시 시작하면 적용됩니다.',
'settings.general.outputStyleScopeLocal': '현재 프로젝트',
'settings.general.outputStyleScopeUser': '사용자 설정',
'settings.general.outputStyleScopeLocalHint': '활성 프로젝트의 .claude/settings.local.json에 저장됩니다.',
'settings.general.outputStyleScopeUserHint': '활성 프로젝트가 선택되지 않아 사용자 설정에 저장됩니다.',
'settings.general.outputStyleSourceBuiltIn': '기본 제공',
'settings.general.outputStyleSourceUser': '사용자 스타일',
'settings.general.outputStyleSourceProject': '프로젝트 스타일',
'settings.general.outputStyleSourceLocal': '로컬 프로젝트 스타일',
'settings.general.outputStyleSourcePolicy': '관리형 스타일',
'settings.general.outputStyleSourcePlugin': '플러그인 스타일',
'settings.general.effortTitle': '노력 수준',
'settings.general.effortDescription': '모델이 사용하는 계산량을 제어합니다.',
'settings.general.effort.low': '낮음',

View File

@ -931,6 +931,22 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.responseLangTitle': '回覆語言',
'settings.general.responseLangDescription': '指定 Claude 始終以某種語言回覆。',
'settings.general.responseLangDefault': '預設(英語)',
'settings.general.outputStyleTitle': '輸出風格',
'settings.general.outputStyleDescription': '選擇 Claude 在新會話或重啟後的會話中如何表達。',
'settings.general.outputStyleSelectLabel': '選擇輸出風格',
'settings.general.outputStyleLoading': '正在載入輸出風格...',
'settings.general.outputStyleSaved': '輸出風格已儲存。',
'settings.general.outputStyleRestartHint': '正在執行的會話會繼續使用目前提示詞。新建會話、在新程序中繼續,或重啟會話後生效。',
'settings.general.outputStyleScopeLocal': '目前專案',
'settings.general.outputStyleScopeUser': '使用者設定',
'settings.general.outputStyleScopeLocalHint': '已儲存到目前專案的 .claude/settings.local.json。',
'settings.general.outputStyleScopeUserHint': '目前未選擇專案,因此儲存到使用者設定。',
'settings.general.outputStyleSourceBuiltIn': '內建',
'settings.general.outputStyleSourceUser': '使用者風格',
'settings.general.outputStyleSourceProject': '專案風格',
'settings.general.outputStyleSourceLocal': '專案本地風格',
'settings.general.outputStyleSourcePolicy': '受管風格',
'settings.general.outputStyleSourcePlugin': '外掛風格',
'settings.general.effortTitle': '推理強度',
'settings.general.effortDescription': '控制模型使用的計算量。',
'settings.general.effort.low': '低',

View File

@ -931,6 +931,22 @@ export const zh: Record<TranslationKey, string> = {
'settings.general.responseLangTitle': '回复语言',
'settings.general.responseLangDescription': '指定 Claude 始终以某种语言回复。',
'settings.general.responseLangDefault': '默认(英语)',
'settings.general.outputStyleTitle': '输出风格',
'settings.general.outputStyleDescription': '选择 Claude 在新会话或重启后的会话中如何表达。',
'settings.general.outputStyleSelectLabel': '选择输出风格',
'settings.general.outputStyleLoading': '正在加载输出风格...',
'settings.general.outputStyleSaved': '输出风格已保存。',
'settings.general.outputStyleRestartHint': '正在运行的会话会继续使用当前提示词。新建会话、在新进程中继续,或重启会话后生效。',
'settings.general.outputStyleScopeLocal': '当前项目',
'settings.general.outputStyleScopeUser': '用户设置',
'settings.general.outputStyleScopeLocalHint': '已保存到当前项目的 .claude/settings.local.json。',
'settings.general.outputStyleScopeUserHint': '当前未选择项目,因此保存到用户设置。',
'settings.general.outputStyleSourceBuiltIn': '内置',
'settings.general.outputStyleSourceUser': '用户风格',
'settings.general.outputStyleSourceProject': '项目风格',
'settings.general.outputStyleSourceLocal': '项目本地风格',
'settings.general.outputStyleSourcePolicy': '受管风格',
'settings.general.outputStyleSourcePlugin': '插件风格',
'settings.general.effortTitle': '推理强度',
'settings.general.effortDescription': '控制模型使用的计算量。',
'settings.general.effort.low': '低',

View File

@ -3,13 +3,13 @@ import QRCode from 'qrcode'
import { Copy, Eye, EyeOff, PowerOff, QrCode, RotateCw } from 'lucide-react'
import { useSettingsStore, UI_ZOOM_DEFAULT, UI_ZOOM_MIN, UI_ZOOM_MAX, UI_ZOOM_STEP } from '../stores/settingsStore'
import { useProviderStore } from '../stores/providerStore'
import { useTranslation } from '../i18n'
import { useTranslation, type TranslationKey } from '../i18n'
import { Modal } from '../components/shared/Modal'
import { ConfirmDialog } from '../components/shared/ConfirmDialog'
import { Input } from '../components/shared/Input'
import { Button } from '../components/shared/Button'
import { Dropdown } from '../components/shared/Dropdown'
import type { ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode, ChatSendBehavior } from '../types/settings'
import type { ThemeMode, UpdateProxyMode, NetworkProxyMode, WebSearchMode, AppMode, ChatSendBehavior, OutputStyleSource } from '../types/settings'
import type { Locale } from '../i18n'
import type { SavedProvider, UpdateProviderInput, ProviderTestResult, ModelMapping, ApiFormat, ProviderAuthStrategy } from '../types/provider'
import type { ProviderPreset } from '../types/providerPreset'
@ -1431,7 +1431,7 @@ function ProviderFormModal({ open, onClose, mode, provider, presets }: ProviderF
// ─── General Settings ──────────────────────────────────────
function GeneralSettings() {
export function GeneralSettings() {
const {
thinkingEnabled,
setThinkingEnabled,
@ -1441,6 +1441,13 @@ function GeneralSettings() {
setTheme,
chatSendBehavior,
setChatSendBehavior,
outputStyle,
outputStyles,
outputStyleScope,
outputStylesLoading,
outputStyleError,
fetchOutputStyles,
setOutputStyle,
skipWebFetchPreflight,
setSkipWebFetchPreflight,
desktopNotificationsEnabled,
@ -1458,6 +1465,8 @@ function GeneralSettings() {
uiZoom,
setUiZoom,
} = useSettingsStore()
const activeSessionId = useSessionStore((s) => s.activeSessionId)
const sessions = useSessionStore((s) => s.sessions)
const t = useTranslation()
const [webSearchDraft, setWebSearchDraft] = useState(webSearch)
const [networkDraft, setNetworkDraft] = useState(network)
@ -1482,11 +1491,23 @@ function GeneralSettings() {
const activeConfigDir = appMode.activeConfigDir ?? (appMode.mode === 'portable' ? appMode.portableDir : null)
const configDirSource = appMode.configDirSource ?? (appMode.mode === 'portable' ? 'portable' : 'system')
const isEnvironmentConfigDir = configDirSource === 'environment'
const activeSession = useMemo(
() => sessions.find((session) => session.id === activeSessionId),
[activeSessionId, sessions],
)
const outputStyleWorkDir =
activeSession?.workDirExists === false
? null
: activeSession?.workDir ?? activeSession?.projectRoot ?? null
useEffect(() => {
setWebSearchDraft(webSearch)
}, [webSearch])
useEffect(() => {
void fetchOutputStyles(outputStyleWorkDir)
}, [fetchOutputStyles, outputStyleWorkDir])
useEffect(() => {
setNetworkDraft(network)
setNetworkTimeoutInput(String(Math.round(network.aiRequestTimeoutMs / 1000)))
@ -1553,6 +1574,19 @@ function GeneralSettings() {
]
const selectedResponseLanguageLabel =
RESPONSE_LANGUAGES.find(({ value }) => value === responseLanguage)?.label ?? RESPONSE_LANGUAGES[0]!.label
const outputStyleItems = outputStyles.map((style) => ({
value: style.value,
label: style.label,
description: `${style.description} · ${getOutputStyleSourceLabel(style.source, t)}`,
}))
const selectedOutputStyle =
outputStyles.find((style) => style.value === outputStyle) ?? outputStyles[0]
const outputStyleScopeLabel = outputStyleScope === 'localSettings'
? t('settings.general.outputStyleScopeLocal')
: t('settings.general.outputStyleScopeUser')
const outputStyleScopeHint = outputStyleScope === 'localSettings'
? t('settings.general.outputStyleScopeLocalHint')
: t('settings.general.outputStyleScopeUserHint')
const THEMES: Array<{ value: ThemeMode; label: string }> = [
{ value: 'white', label: t('settings.general.appearance.white') },
@ -1719,6 +1753,18 @@ function GeneralSettings() {
}
}
const handleOutputStyleChange = async (value: string) => {
try {
await setOutputStyle(value, outputStyleWorkDir)
addToast({
type: 'success',
message: t('settings.general.outputStyleSaved'),
})
} catch {
// The store exposes outputStyleError below; keep the interaction local.
}
}
const openPortableDirPicker = async () => {
setModeError(null)
const host = getDesktopHost()
@ -1964,6 +2010,62 @@ function GeneralSettings() {
}
/>
{/* Output style */}
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.outputStyleTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.outputStyleDescription')}</p>
<div className="mb-8 rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
<Dropdown<string>
items={outputStyleItems}
value={outputStyle}
onChange={(value) => void handleOutputStyleChange(value)}
width="100%"
maxHeight={360}
className="block w-full"
trigger={
<button
type="button"
aria-label={t('settings.general.outputStyleSelectLabel')}
disabled={outputStylesLoading}
className="flex min-h-10 w-full items-center gap-3 rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-left text-sm text-[var(--color-text-primary)] outline-none transition-colors hover:border-[var(--color-border-focus)] hover:bg-[var(--color-surface-container-low)] focus-visible:border-[var(--color-border-focus)] focus-visible:shadow-[var(--shadow-focus-ring)] disabled:cursor-not-allowed disabled:opacity-60"
>
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">format_paint</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{outputStylesLoading
? t('settings.general.outputStyleLoading')
: selectedOutputStyle?.label ?? outputStyle}
</span>
{selectedOutputStyle?.description && (
<span className="mt-0.5 block truncate text-xs text-[var(--color-text-tertiary)]">
{selectedOutputStyle.description}
</span>
)}
</span>
<span className="material-symbols-outlined flex-shrink-0 text-[18px] text-[var(--color-text-secondary)]">expand_more</span>
</button>
}
/>
<div className="mt-3 flex flex-wrap items-center gap-2 text-xs text-[var(--color-text-tertiary)]">
<span className="inline-flex items-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1 font-medium text-[var(--color-text-secondary)]">
{outputStyleScopeLabel}
</span>
{selectedOutputStyle && (
<span className="inline-flex items-center rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-2 py-1">
{getOutputStyleSourceLabel(selectedOutputStyle.source, t)}
</span>
)}
<span className="min-w-0 flex-1 leading-5">{outputStyleScopeHint}</span>
</div>
<p className="mt-2 text-xs leading-5 text-[var(--color-text-tertiary)]">
{t('settings.general.outputStyleRestartHint')}
</p>
{outputStyleError && (
<p className="mt-2 text-xs leading-5 text-[var(--color-error)]">
{outputStyleError}
</p>
)}
</div>
<div className="mt-8">
<h2 className="text-base font-semibold text-[var(--color-text-primary)] mb-1">{t('settings.general.thinkingTitle')}</h2>
<p className="text-sm text-[var(--color-text-tertiary)] mb-3">{t('settings.general.thinkingDescription')}</p>
@ -2484,6 +2586,26 @@ function GeneralSettings() {
)
}
function getOutputStyleSourceLabel(
source: OutputStyleSource,
t: (key: TranslationKey) => string,
) {
switch (source) {
case 'built-in':
return t('settings.general.outputStyleSourceBuiltIn')
case 'userSettings':
return t('settings.general.outputStyleSourceUser')
case 'projectSettings':
return t('settings.general.outputStyleSourceProject')
case 'localSettings':
return t('settings.general.outputStyleSourceLocal')
case 'policySettings':
return t('settings.general.outputStyleSourcePolicy')
case 'plugin':
return t('settings.general.outputStyleSourcePlugin')
}
}
// ─── H5 Access Settings ──────────────────────────────────────
function H5AccessSettings() {

View File

@ -0,0 +1,128 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
const { settingsApiMock } = vi.hoisted(() => ({
settingsApiMock: {
getPermissionMode: vi.fn(),
getUser: vi.fn(),
updateUser: vi.fn(),
getOutputStyles: vi.fn(),
setOutputStyle: vi.fn(),
},
}))
vi.mock('../api/settings', () => ({
settingsApi: settingsApiMock,
}))
vi.mock('../api/models', () => ({
modelsApi: {
list: vi.fn(),
getCurrent: vi.fn(),
getEffort: vi.fn(),
setCurrent: vi.fn(),
setEffort: vi.fn(),
},
}))
vi.mock('../api/h5Access', () => ({
h5AccessApi: {
get: vi.fn(),
enable: vi.fn(),
disable: vi.fn(),
regenerate: vi.fn(),
update: vi.fn(),
},
}))
vi.mock('../lib/desktopNotifications', () => ({
getDesktopNotificationPermission: vi.fn().mockResolvedValue('unsupported'),
notifyDesktop: vi.fn(),
openDesktopNotificationSettings: vi.fn(),
requestDesktopNotificationPermission: vi.fn().mockResolvedValue('unsupported'),
}))
vi.mock('../lib/desktopRuntime', () => ({
isDesktopRuntime: () => false,
}))
import { GeneralSettings } from './Settings'
import { useSessionStore } from '../stores/sessionStore'
import { useSettingsStore } from '../stores/settingsStore'
describe('GeneralSettings output style', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState(useSettingsStore.getInitialState(), true)
useSessionStore.setState(useSessionStore.getInitialState(), true)
useSettingsStore.setState({ locale: 'en' })
useSessionStore.setState({
activeSessionId: 'session-1',
sessions: [
{
id: 'session-1',
title: 'Project session',
createdAt: '2026-06-09T00:00:00.000Z',
modifiedAt: '2026-06-09T00:00:00.000Z',
messageCount: 0,
projectPath: '/repo',
projectRoot: '/repo',
workDir: '/repo',
workDirExists: true,
},
],
})
settingsApiMock.getOutputStyles.mockResolvedValue({
outputStyle: 'Project Style',
scope: 'localSettings',
workDir: '/repo',
styles: [
{
value: 'default',
label: 'Default',
description: 'Default style',
source: 'built-in',
},
{
value: 'Project Style',
label: 'Project Style',
description: 'Project custom voice',
source: 'projectSettings',
},
{
value: 'Learning',
label: 'Learning',
description: 'Hands-on practice',
source: 'built-in',
},
],
})
settingsApiMock.setOutputStyle.mockResolvedValue({
ok: true,
outputStyle: 'Learning',
scope: 'localSettings',
workDir: '/repo',
})
})
it('renders project output styles and saves the selected style', async () => {
render(<GeneralSettings />)
expect(await screen.findByText('Project Style')).toBeInTheDocument()
expect(settingsApiMock.getOutputStyles).toHaveBeenCalledWith('/repo')
expect(screen.getByText('Saved to .claude/settings.local.json for the active project.')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: 'Select output style' }))
fireEvent.click(screen.getByText('Learning'))
await waitFor(() => {
expect(settingsApiMock.setOutputStyle).toHaveBeenCalledWith('Learning', '/repo')
})
await act(async () => {
await Promise.resolve()
})
expect(useSettingsStore.getState().outputStyle).toBe('Learning')
})
})

View File

@ -13,6 +13,8 @@ import {
type H5AccessDiagnostics,
type H5AccessSettings,
type NetworkSettings,
type OutputStyleOption,
type OutputStylesResponse,
type PermissionMode,
type EffortLevel,
type ModelInfo,
@ -61,6 +63,12 @@ type SettingsStore = {
locale: Locale
theme: ThemeMode
chatSendBehavior: ChatSendBehavior
outputStyle: string
outputStyles: OutputStyleOption[]
outputStyleScope: OutputStylesResponse['scope']
outputStyleWorkDir: string | null
outputStylesLoading: boolean
outputStyleError: string | null
skipWebFetchPreflight: boolean
desktopNotificationsEnabled: boolean
desktopTerminal: DesktopTerminalSettings
@ -87,6 +95,8 @@ type SettingsStore = {
setLocale: (locale: Locale) => void
setTheme: (theme: ThemeMode) => Promise<void>
setChatSendBehavior: (behavior: ChatSendBehavior) => Promise<void>
fetchOutputStyles: (workDir?: string | null) => Promise<void>
setOutputStyle: (outputStyle: string, workDir?: string | null) => Promise<void>
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
setDesktopNotificationsEnabled: (enabled: boolean) => Promise<void>
setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise<void>
@ -135,6 +145,16 @@ const DEFAULT_NETWORK_SETTINGS: NetworkSettings = {
},
}
const DEFAULT_OUTPUT_STYLE = 'default'
const DEFAULT_OUTPUT_STYLE_OPTIONS: OutputStyleOption[] = [
{
value: DEFAULT_OUTPUT_STYLE,
label: 'Default',
description: 'Claude completes coding tasks efficiently and provides concise responses',
source: 'built-in',
},
]
export const useSettingsStore = create<SettingsStore>((set, get) => ({
permissionMode: 'default',
currentModel: null,
@ -145,6 +165,12 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
locale: getStoredLocale(),
theme: useUIStore.getState().theme,
chatSendBehavior: 'enter',
outputStyle: DEFAULT_OUTPUT_STYLE,
outputStyles: DEFAULT_OUTPUT_STYLE_OPTIONS,
outputStyleScope: 'userSettings',
outputStyleWorkDir: null,
outputStylesLoading: false,
outputStyleError: null,
skipWebFetchPreflight: true,
desktopNotificationsEnabled: false,
desktopTerminal: DEFAULT_DESKTOP_TERMINAL_SETTINGS,
@ -196,6 +222,7 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
thinkingEnabled: userSettings.alwaysThinkingEnabled !== false,
theme,
chatSendBehavior: normalizeChatSendBehavior(userSettings.chatSendBehavior),
outputStyle: normalizeOutputStyle(userSettings.outputStyle),
skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false,
desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled === true,
desktopTerminal: normalizeDesktopTerminalSettings(userSettings.desktopTerminal),
@ -291,6 +318,57 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
}
},
fetchOutputStyles: async (workDir) => {
set({ outputStylesLoading: true, outputStyleError: null })
try {
const response = await settingsApi.getOutputStyles(workDir)
set({
outputStyle: normalizeOutputStyle(response.outputStyle),
outputStyles: normalizeOutputStyleOptions(response.styles),
outputStyleScope: response.scope,
outputStyleWorkDir: response.workDir,
outputStylesLoading: false,
outputStyleError: null,
})
} catch (error) {
set({
outputStylesLoading: false,
outputStyleError: getErrorMessage(error, 'Failed to load output styles.'),
})
throw error
}
},
setOutputStyle: async (outputStyle, workDir) => {
const prev = {
outputStyle: get().outputStyle,
outputStyleScope: get().outputStyleScope,
outputStyleWorkDir: get().outputStyleWorkDir,
outputStyleError: get().outputStyleError,
}
set({
outputStyle,
outputStyleError: null,
})
try {
const result = await settingsApi.setOutputStyle(outputStyle, workDir)
set({
outputStyle: normalizeOutputStyle(result.outputStyle),
outputStyleScope: result.scope,
outputStyleWorkDir: result.workDir,
outputStyleError: null,
})
} catch (error) {
set({
outputStyle: prev.outputStyle,
outputStyleScope: prev.outputStyleScope,
outputStyleWorkDir: prev.outputStyleWorkDir,
outputStyleError: getErrorMessage(error, 'Failed to save output style.'),
})
throw error
}
},
setSkipWebFetchPreflight: async (enabled) => {
const prev = get().skipWebFetchPreflight
set({ skipWebFetchPreflight: enabled })
@ -489,6 +567,30 @@ function normalizeChatSendBehavior(value: unknown): ChatSendBehavior {
return value === 'modifierEnter' ? 'modifierEnter' : 'enter'
}
function normalizeOutputStyle(value: unknown): string {
return typeof value === 'string' && value.trim().length > 0
? value
: DEFAULT_OUTPUT_STYLE
}
function normalizeOutputStyleOptions(styles: OutputStyleOption[] | undefined): OutputStyleOption[] {
if (!Array.isArray(styles) || styles.length === 0) return DEFAULT_OUTPUT_STYLE_OPTIONS
const normalized = styles
.filter((style): style is OutputStyleOption =>
typeof style?.value === 'string' &&
style.value.trim().length > 0 &&
typeof style.label === 'string' &&
typeof style.description === 'string',
)
.map(style => ({
...style,
value: style.value.trim(),
label: style.label.trim() || style.value.trim(),
description: style.description.trim(),
}))
return normalized.length > 0 ? normalized : DEFAULT_OUTPUT_STYLE_OPTIONS
}
function isUpdateProxyMode(value: unknown): value is UpdateProxyMode {
return value === 'system' || value === 'manual'
}

View File

@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { settingsApiMock } = vi.hoisted(() => ({
settingsApiMock: {
getPermissionMode: vi.fn(),
getUser: vi.fn(),
updateUser: vi.fn(),
getOutputStyles: vi.fn(),
setOutputStyle: vi.fn(),
},
}))
vi.mock('../api/settings', () => ({
settingsApi: settingsApiMock,
}))
vi.mock('../api/models', () => ({
modelsApi: {
list: vi.fn(),
getCurrent: vi.fn(),
getEffort: vi.fn(),
setCurrent: vi.fn(),
setEffort: vi.fn(),
},
}))
vi.mock('../api/h5Access', () => ({
h5AccessApi: {
get: vi.fn(),
enable: vi.fn(),
disable: vi.fn(),
regenerate: vi.fn(),
update: vi.fn(),
},
}))
import { useSettingsStore } from './settingsStore'
describe('settingsStore output styles', () => {
beforeEach(() => {
vi.clearAllMocks()
useSettingsStore.setState(useSettingsStore.getInitialState(), true)
})
it('loads output styles for the active workdir', async () => {
settingsApiMock.getOutputStyles.mockResolvedValue({
outputStyle: 'Project Style',
scope: 'localSettings',
workDir: '/repo',
styles: [
{
value: 'default',
label: 'Default',
description: 'Default style',
source: 'built-in',
},
{
value: 'Project Style',
label: 'Project Style',
description: 'Project custom voice',
source: 'projectSettings',
},
],
})
await useSettingsStore.getState().fetchOutputStyles('/repo')
expect(settingsApiMock.getOutputStyles).toHaveBeenCalledWith('/repo')
expect(useSettingsStore.getState().outputStyle).toBe('Project Style')
expect(useSettingsStore.getState().outputStyleScope).toBe('localSettings')
expect(useSettingsStore.getState().outputStyleWorkDir).toBe('/repo')
expect(useSettingsStore.getState().outputStyles).toContainEqual(
expect.objectContaining({
value: 'Project Style',
source: 'projectSettings',
}),
)
})
it('saves output style and rolls back on failure', async () => {
useSettingsStore.setState({
outputStyle: 'default',
outputStyleScope: 'localSettings',
outputStyleWorkDir: '/repo',
outputStyleError: null,
})
settingsApiMock.setOutputStyle.mockRejectedValueOnce(new Error('save failed'))
await expect(
useSettingsStore.getState().setOutputStyle('Learning', '/repo'),
).rejects.toThrow('save failed')
expect(settingsApiMock.setOutputStyle).toHaveBeenCalledWith('Learning', '/repo')
expect(useSettingsStore.getState().outputStyle).toBe('default')
expect(useSettingsStore.getState().outputStyleError).toBe('save failed')
settingsApiMock.setOutputStyle.mockResolvedValueOnce({
ok: true,
outputStyle: 'Learning',
scope: 'localSettings',
workDir: '/repo',
})
await useSettingsStore.getState().setOutputStyle('Learning', '/repo')
expect(useSettingsStore.getState().outputStyle).toBe('Learning')
expect(useSettingsStore.getState().outputStyleError).toBeNull()
})
})

View File

@ -14,6 +14,28 @@ export type WebSearchMode = 'auto' | 'anthropic' | 'tavily' | 'brave' | 'disable
export type ChatSendBehavior = 'enter' | 'modifierEnter'
export type OutputStyleSource =
| 'built-in'
| 'userSettings'
| 'projectSettings'
| 'localSettings'
| 'policySettings'
| 'plugin'
export type OutputStyleOption = {
value: string
label: string
description: string
source: OutputStyleSource
}
export type OutputStylesResponse = {
outputStyle: string
styles: OutputStyleOption[]
scope: 'userSettings' | 'localSettings'
workDir: string | null
}
export type WebSearchSettings = {
mode?: WebSearchMode
tavilyApiKey?: string
@ -83,6 +105,7 @@ export type UserSettings = {
permissionMode?: PermissionMode
theme?: ThemeMode
chatSendBehavior?: ChatSendBehavior
outputStyle?: string
skipWebFetchPreflight?: boolean
desktopNotificationsEnabled?: boolean
webSearch?: WebSearchSettings

View File

@ -27,6 +27,8 @@ import {
updateSettingsForSource,
} from '../../utils/settings/settings.js'
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
import { clearAllOutputStylesCache } from '../../constants/outputStyles.js'
import { clearOutputStyleCaches } from '../../outputStyles/loadOutputStylesDir.js'
// ─── Test helpers ─────────────────────────────────────────────────────────────
@ -47,6 +49,8 @@ let originalAnthropicDefaultOpusModel: string | undefined
async function setup() {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-test-'))
resetSettingsCache()
clearAllOutputStylesCache()
clearOutputStyleCaches()
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
originalHome = process.env.HOME
originalUserProfile = process.env.USERPROFILE
@ -80,6 +84,8 @@ async function teardown() {
clearKeychainCache()
clearOpenAIOAuthTokenCache()
resetSettingsCache()
clearAllOutputStylesCache()
clearOutputStyleCaches()
if (originalConfigDir !== undefined) {
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
@ -272,6 +278,24 @@ describe('SettingsService', () => {
expect(settings.outputStyle).toBe('verbose')
})
it('should read and write project-local settings', async () => {
const projectRoot = path.join(tmpDir, 'myproject')
const svc = new SettingsService(projectRoot)
await svc.updateLocalSettings({
outputStyle: 'Learning',
preserved: true,
})
await svc.updateLocalSettings({
theme: 'dark',
})
const settings = await svc.getLocalSettings()
expect(settings.outputStyle).toBe('Learning')
expect(settings.preserved).toBe(true)
expect(settings.theme).toBe('dark')
})
it('should merge user and project settings', async () => {
const projectRoot = path.join(tmpDir, 'myproject')
await fs.mkdir(path.join(projectRoot, '.claude'), { recursive: true })
@ -417,6 +441,106 @@ describe('Settings API', () => {
}
})
it('GET /api/settings/output-styles should include built-in, user, and project styles', async () => {
const projectRoot = path.join(tmpDir, 'myproject')
await fs.mkdir(path.join(tmpDir, 'output-styles'), { recursive: true })
await fs.mkdir(path.join(projectRoot, '.claude', 'output-styles'), { recursive: true })
await fs.writeFile(
path.join(tmpDir, 'output-styles', 'user-style.md'),
[
'---',
'name: User Style',
'description: User custom voice',
'keep-coding-instructions: true',
'---',
'User prompt',
].join('\n'),
'utf-8',
)
await fs.writeFile(
path.join(projectRoot, '.claude', 'output-styles', 'project-style.md'),
[
'---',
'name: Project Style',
'description: Project custom voice',
'---',
'Project prompt',
].join('\n'),
'utf-8',
)
await fs.writeFile(
path.join(projectRoot, '.claude', 'settings.local.json'),
JSON.stringify({ outputStyle: 'Project Style' }),
'utf-8',
)
clearAllOutputStylesCache()
clearOutputStyleCaches()
const { req, url, segments } = makeRequest(
'GET',
`/api/settings/output-styles?workDir=${encodeURIComponent(projectRoot)}`,
)
const res = await handleSettingsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json()
expect(body.outputStyle).toBe('Project Style')
expect(body.scope).toBe('localSettings')
expect(body.workDir).toBe(projectRoot)
expect(body.styles).toEqual(
expect.arrayContaining([
expect.objectContaining({ value: 'default', source: 'built-in' }),
expect.objectContaining({ value: 'Explanatory', source: 'built-in' }),
expect.objectContaining({ value: 'Learning', source: 'built-in' }),
expect.objectContaining({ value: 'User Style', source: 'userSettings' }),
expect.objectContaining({ value: 'Project Style', source: 'projectSettings' }),
]),
)
})
it('PUT /api/settings/output-style should save to project-local settings and preserve fields', async () => {
const projectRoot = path.join(tmpDir, 'myproject')
await fs.mkdir(path.join(projectRoot, '.claude'), { recursive: true })
await fs.writeFile(
path.join(projectRoot, '.claude', 'settings.local.json'),
JSON.stringify({ preserved: 'yes' }),
'utf-8',
)
const { req, url, segments } = makeRequest('PUT', '/api/settings/output-style', {
outputStyle: 'Learning',
workDir: projectRoot,
})
const res = await handleSettingsApi(req, url, segments)
expect(res.status).toBe(200)
const body = await res.json()
expect(body).toMatchObject({
ok: true,
outputStyle: 'Learning',
scope: 'localSettings',
workDir: projectRoot,
})
const raw = await fs.readFile(
path.join(projectRoot, '.claude', 'settings.local.json'),
'utf-8',
)
const settings = JSON.parse(raw)
expect(settings.outputStyle).toBe('Learning')
expect(settings.preserved).toBe('yes')
})
it('PUT /api/settings/output-style should reject unavailable styles', async () => {
const { req, url, segments } = makeRequest('PUT', '/api/settings/output-style', {
outputStyle: 'Missing Style',
})
const res = await handleSettingsApi(req, url, segments)
expect(res.status).toBe(400)
})
it('GET /api/settings/cli-launcher should expose bundled launcher status', async () => {
if (process.platform === 'win32') return

View File

@ -14,9 +14,30 @@ import { SettingsService } from '../services/settingsService.js'
import { ApiError, errorResponse } from '../middleware/errorHandler.js'
import { ensureDesktopCliLauncherInstalled } from '../services/desktopCliLauncherService.js'
import { conversationService } from '../services/conversationService.js'
import {
DEFAULT_OUTPUT_STYLE_NAME,
getAllOutputStyles,
type OutputStyleConfig,
} from '../../constants/outputStyles.js'
import { getCwd } from '../../utils/cwd.js'
const settingsService = new SettingsService()
type OutputStyleSource =
| OutputStyleConfig['source']
| 'built-in'
type OutputStyleListItem = {
value: string
label: string
description: string
source: OutputStyleSource
}
const DEFAULT_OUTPUT_STYLE_LABEL = 'Default'
const DEFAULT_OUTPUT_STYLE_DESCRIPTION =
'Claude completes coding tasks efficiently and provides concise responses'
export async function handleSettingsApi(
req: Request,
url: URL,
@ -49,6 +70,12 @@ export async function handleSettingsApi(
case 'project':
return await handleProjectSettings(req, url)
case 'output-styles':
return await handleOutputStyles(req, url)
case 'output-style':
return await handleOutputStyle(req)
case 'cli-launcher':
if (method !== 'GET') throw methodNotAllowed(method)
return Response.json(await ensureDesktopCliLauncherInstalled())
@ -94,6 +121,73 @@ async function handleProjectSettings(req: Request, url: URL): Promise<Response>
throw methodNotAllowed(req.method)
}
async function handleOutputStyles(req: Request, url: URL): Promise<Response> {
if (req.method !== 'GET') {
throw methodNotAllowed(req.method)
}
const workDir = getWorkDirFromUrl(url)
const styles = await listOutputStyles(workDir)
const settings = workDir
? Object.assign(
{},
await settingsService.getUserSettings(),
await settingsService.getProjectSettings(workDir).catch(() => ({})),
await settingsService.getLocalSettings(workDir).catch(() => ({})),
)
: await settingsService.getUserSettings()
const outputStyle =
typeof settings.outputStyle === 'string'
? settings.outputStyle
: DEFAULT_OUTPUT_STYLE_NAME
return Response.json({
outputStyle,
styles,
scope: workDir ? 'localSettings' : 'userSettings',
workDir: workDir ?? null,
})
}
async function handleOutputStyle(req: Request): Promise<Response> {
if (req.method !== 'PUT') {
throw methodNotAllowed(req.method)
}
const body = await parseJsonBody(req)
const outputStyle = body.outputStyle
if (typeof outputStyle !== 'string' || outputStyle.trim().length === 0) {
throw ApiError.badRequest('Missing or invalid "outputStyle" in request body')
}
const workDir =
typeof body.workDir === 'string' && body.workDir.trim().length > 0
? body.workDir
: undefined
const styles = await listOutputStyles(workDir)
if (!styles.some(style => style.value === outputStyle)) {
throw ApiError.badRequest(`Unknown output style: "${outputStyle}"`)
}
if (workDir) {
await settingsService.updateLocalSettings({ outputStyle }, workDir)
return Response.json({
ok: true,
outputStyle,
scope: 'localSettings',
workDir,
})
}
await settingsService.updateUserSettings({ outputStyle })
return Response.json({
ok: true,
outputStyle,
scope: 'userSettings',
workDir: null,
})
}
async function handlePermissionMode(req: Request): Promise<Response> {
if (req.method === 'GET') {
const mode = await settingsService.getPermissionMode()
@ -127,6 +221,25 @@ function methodNotAllowed(method: string): ApiError {
return new ApiError(405, `Method ${method} not allowed`, 'METHOD_NOT_ALLOWED')
}
function getWorkDirFromUrl(url: URL): string | undefined {
const raw = url.searchParams.get('workDir')
if (typeof raw === 'string' && raw.trim().length > 0) {
return raw
}
return undefined
}
async function listOutputStyles(workDir?: string): Promise<OutputStyleListItem[]> {
const cwd = workDir ?? getCwd()
const styles = await getAllOutputStyles(cwd)
return Object.entries(styles).map(([value, config]) => ({
value,
label: config?.name ?? DEFAULT_OUTPUT_STYLE_LABEL,
description: config?.description ?? DEFAULT_OUTPUT_STYLE_DESCRIPTION,
source: config?.source ?? 'built-in',
}))
}
function syncThinkingSettingToActiveSessions(settings: Record<string, unknown>): void {
if (
!Object.prototype.hasOwnProperty.call(settings, 'alwaysThinkingEnabled') ||

View File

@ -16,6 +16,7 @@ import { ApiError } from '../middleware/errorHandler.js'
import { normalizeJsonObject, readRecoverableJsonFile } from './recoverableJsonFile.js'
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
import { addFileGlobRuleToGitignore } from '../../utils/git/gitignore.js'
const VALID_PERMISSION_MODES = [
'default',
@ -54,6 +55,15 @@ export class SettingsService {
return path.join(root, '.claude', 'settings.json')
}
/** 项目本地设置文件路径(不建议提交到仓库) */
private getLocalSettingsPath(projectRoot?: string): string {
const root = projectRoot || this.projectRoot
if (!root) {
throw ApiError.badRequest('Project root is required for local settings')
}
return path.join(root, '.claude', 'settings.local.json')
}
// ---------------------------------------------------------------------------
// 读取
// ---------------------------------------------------------------------------
@ -91,6 +101,11 @@ export class SettingsService {
return this.readJsonFile(this.getProjectSettingsPath(projectRoot))
}
/** 获取项目本地设置 */
async getLocalSettings(projectRoot?: string): Promise<Record<string, unknown>> {
return this.readJsonFile(this.getLocalSettingsPath(projectRoot))
}
// ---------------------------------------------------------------------------
// 写入(原子写入:先写临时文件,再 rename
// ---------------------------------------------------------------------------
@ -173,6 +188,23 @@ export class SettingsService {
})
}
/** 更新项目本地设置(浅合并) */
async updateLocalSettings(
settings: Record<string, unknown>,
projectRoot?: string,
): Promise<void> {
const root = projectRoot || this.projectRoot
const filePath = this.getLocalSettingsPath(projectRoot)
await this.withWriteLock(filePath, async () => {
const current = await this.readJsonFile(filePath)
const merged = Object.assign({}, current, settings)
await this.writeJsonFile(filePath, merged)
})
if (root) {
void addFileGlobRuleToGitignore('.claude/settings.local.json', root)
}
}
// ---------------------------------------------------------------------------
// 权限模式
// ---------------------------------------------------------------------------