mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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:
parent
e8bf789961
commit
56661c7967
@ -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 })
|
||||
}),
|
||||
|
||||
@ -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')
|
||||
},
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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) */
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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': '低',
|
||||
|
||||
@ -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': '낮음',
|
||||
|
||||
@ -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': '低',
|
||||
|
||||
@ -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': '低',
|
||||
|
||||
@ -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() {
|
||||
|
||||
128
desktop/src/pages/SettingsOutputStyle.test.tsx
Normal file
128
desktop/src/pages/SettingsOutputStyle.test.tsx
Normal 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')
|
||||
})
|
||||
})
|
||||
@ -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'
|
||||
}
|
||||
|
||||
109
desktop/src/stores/settingsStoreOutputStyle.test.ts
Normal file
109
desktop/src/stores/settingsStoreOutputStyle.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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') ||
|
||||
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 权限模式
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user