From 56661c79677395cdc9d110c1d0a0862e8e7c3d80 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 10 Jun 2026 15:24:20 +0800 Subject: [PATCH] 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 --- .../src/__tests__/generalSettings.test.tsx | 17 +++ desktop/src/api/settings.ts | 19 ++- .../src/components/chat/composerUtils.test.ts | 4 + desktop/src/components/chat/composerUtils.ts | 2 + desktop/src/i18n/locales/en.ts | 16 +++ desktop/src/i18n/locales/jp.ts | 16 +++ desktop/src/i18n/locales/kr.ts | 16 +++ desktop/src/i18n/locales/zh-TW.ts | 16 +++ desktop/src/i18n/locales/zh.ts | 16 +++ desktop/src/pages/Settings.tsx | 128 +++++++++++++++++- .../src/pages/SettingsOutputStyle.test.tsx | 128 ++++++++++++++++++ desktop/src/stores/settingsStore.ts | 102 ++++++++++++++ .../stores/settingsStoreOutputStyle.test.ts | 109 +++++++++++++++ desktop/src/types/settings.ts | 23 ++++ src/server/__tests__/settings.test.ts | 124 +++++++++++++++++ src/server/api/settings.ts | 113 ++++++++++++++++ src/server/services/settingsService.ts | 32 +++++ 17 files changed, 877 insertions(+), 4 deletions(-) create mode 100644 desktop/src/pages/SettingsOutputStyle.test.tsx create mode 100644 desktop/src/stores/settingsStoreOutputStyle.test.ts diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 217ebdbe..42834878 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -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 }) }), diff --git a/desktop/src/api/settings.ts b/desktop/src/api/settings.ts index 4255f943..907bfbde 100644 --- a/desktop/src/api/settings.ts +++ b/desktop/src/api/settings.ts @@ -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(`/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') }, diff --git a/desktop/src/components/chat/composerUtils.test.ts b/desktop/src/components/chat/composerUtils.test.ts index 0fa899df..55478f7e 100644 --- a/desktop/src/components/chat/composerUtils.test.ts +++ b/desktop/src/components/chat/composerUtils.test.ts @@ -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', () => { diff --git a/desktop/src/components/chat/composerUtils.ts b/desktop/src/components/chat/composerUtils.ts index f385103e..2c442831 100644 --- a/desktop/src/components/chat/composerUtils.ts +++ b/desktop/src/components/chat/composerUtils.ts @@ -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) */ diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 94b574f5..096aad3c 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 4b91e5a5..667a19b7 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -931,6 +931,22 @@ export const jp: Record = { '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': '低', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index f90aed74..827896a4 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -931,6 +931,22 @@ export const kr: Record = { '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': '낮음', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index 778c739f..5eb07029 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -931,6 +931,22 @@ export const zh: Record = { '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': '低', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index e1de6996..a9d5661f 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -931,6 +931,22 @@ export const zh: Record = { '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': '低', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index e9f25d9f..b972cb35 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -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 */} +

{t('settings.general.outputStyleTitle')}

+

{t('settings.general.outputStyleDescription')}

+
+ + items={outputStyleItems} + value={outputStyle} + onChange={(value) => void handleOutputStyleChange(value)} + width="100%" + maxHeight={360} + className="block w-full" + trigger={ + + } + /> +
+ + {outputStyleScopeLabel} + + {selectedOutputStyle && ( + + {getOutputStyleSourceLabel(selectedOutputStyle.source, t)} + + )} + {outputStyleScopeHint} +
+

+ {t('settings.general.outputStyleRestartHint')} +

+ {outputStyleError && ( +

+ {outputStyleError} +

+ )} +
+

{t('settings.general.thinkingTitle')}

{t('settings.general.thinkingDescription')}

@@ -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() { diff --git a/desktop/src/pages/SettingsOutputStyle.test.tsx b/desktop/src/pages/SettingsOutputStyle.test.tsx new file mode 100644 index 00000000..14dc6c94 --- /dev/null +++ b/desktop/src/pages/SettingsOutputStyle.test.tsx @@ -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() + + 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') + }) +}) diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index e7999210..41fce78c 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -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 setChatSendBehavior: (behavior: ChatSendBehavior) => Promise + fetchOutputStyles: (workDir?: string | null) => Promise + setOutputStyle: (outputStyle: string, workDir?: string | null) => Promise setSkipWebFetchPreflight: (enabled: boolean) => Promise setDesktopNotificationsEnabled: (enabled: boolean) => Promise setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise @@ -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((set, get) => ({ permissionMode: 'default', currentModel: null, @@ -145,6 +165,12 @@ export const useSettingsStore = create((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((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((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' } diff --git a/desktop/src/stores/settingsStoreOutputStyle.test.ts b/desktop/src/stores/settingsStoreOutputStyle.test.ts new file mode 100644 index 00000000..ddf9617f --- /dev/null +++ b/desktop/src/stores/settingsStoreOutputStyle.test.ts @@ -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() + }) +}) diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index 57061d4c..c3066f7d 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -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 diff --git a/src/server/__tests__/settings.test.ts b/src/server/__tests__/settings.test.ts index 4d3aade9..d789db3a 100644 --- a/src/server/__tests__/settings.test.ts +++ b/src/server/__tests__/settings.test.ts @@ -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 diff --git a/src/server/api/settings.ts b/src/server/api/settings.ts index 93b2999c..4f49cd13 100644 --- a/src/server/api/settings.ts +++ b/src/server/api/settings.ts @@ -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 throw methodNotAllowed(req.method) } +async function handleOutputStyles(req: Request, url: URL): Promise { + 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 { + 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 { 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 { + 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): void { if ( !Object.prototype.hasOwnProperty.call(settings, 'alwaysThinkingEnabled') || diff --git a/src/server/services/settingsService.ts b/src/server/services/settingsService.ts index 6b988ef7..dbea873c 100644 --- a/src/server/services/settingsService.ts +++ b/src/server/services/settingsService.ts @@ -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> { + return this.readJsonFile(this.getLocalSettingsPath(projectRoot)) + } + // --------------------------------------------------------------------------- // 写入(原子写入:先写临时文件,再 rename) // --------------------------------------------------------------------------- @@ -173,6 +188,23 @@ export class SettingsService { }) } + /** 更新项目本地设置(浅合并) */ + async updateLocalSettings( + settings: Record, + projectRoot?: string, + ): Promise { + 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) + } + } + // --------------------------------------------------------------------------- // 权限模式 // ---------------------------------------------------------------------------