From 8ff53534551f06ed893f5d8b1feed5edcd1f5749 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: Sun, 17 May 2026 18:21:13 +0800 Subject: [PATCH] Let Windows users choose a usable desktop terminal shell The desktop terminal defaulted to COMSPEC on Windows, which commonly lands on cmd.exe and makes the settings terminal feel broken for users who expect PowerShell. This change adds a desktop-only terminal startup-shell setting in Settings, keeps the existing system default untouched unless the user opts in, and resolves the selected shell inside the Tauri PTY spawn path so docked terminals, terminal tabs, and the settings terminal stay aligned. Constraint: Existing terminal sessions and non-Windows defaults must keep their current behavior unless a Windows user explicitly changes the setting Rejected: Reuse settings.defaultShell | that setting already controls CLI ! commands and would couple unrelated shell semantics Rejected: Flip Windows default to pwsh automatically | too risky for users whose current COMSPEC-based terminal already works Confidence: high Scope-risk: moderate Reversibility: clean Directive: Keep desktop terminal shell selection separate from CLI shell routing unless both paths are redesigned together Tested: cd desktop && bun run test -- src/stores/settingsStore.test.ts src/pages/TerminalSettings.test.tsx Tested: cd desktop/src-tauri && cargo test desktop_terminal_shell_resolution -- --nocapture Tested: bun run check:desktop Tested: bun run check:native Not-tested: Real interactive spawn on a physical Windows machine --- desktop/src-tauri/src/lib.rs | 140 ++++++++++++++++- desktop/src/i18n/locales/en.ts | 19 +++ desktop/src/i18n/locales/zh.ts | 19 +++ desktop/src/pages/Settings.tsx | 2 +- desktop/src/pages/TerminalSettings.test.tsx | 20 +++ desktop/src/pages/TerminalSettings.tsx | 160 +++++++++++++++++++- desktop/src/stores/settingsStore.test.ts | 121 +++++++++++++++ desktop/src/stores/settingsStore.ts | 56 ++++++- desktop/src/types/settings.ts | 13 ++ 9 files changed, 545 insertions(+), 5 deletions(-) diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 6d52f5c2..060ad61c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -297,6 +297,25 @@ struct TerminalExitPayload { signal: Option, } +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct DesktopTerminalSettingsFile { + desktop_terminal: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +struct DesktopTerminalConfig { + startup_shell: Option, + custom_shell_path: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TerminalHostPlatform { + Windows, + Posix, +} + #[tauri::command] fn get_server_url(state: State<'_, ServerState>) -> Result { let guard = state @@ -612,7 +631,7 @@ fn terminal_spawn( cwd: Option, ) -> Result { let cwd_path = resolve_terminal_cwd(cwd)?; - let shell = default_shell(); + let shell = resolved_terminal_shell()?; let pty_system = native_pty_system(); let pair = pty_system .openpty(PtySize { @@ -1033,6 +1052,78 @@ fn home_dir() -> Option { .map(PathBuf::from) } +fn claude_config_dir() -> Option { + std::env::var_os("CLAUDE_CONFIG_DIR") + .map(PathBuf::from) + .or_else(|| home_dir().map(|path| path.join(".claude"))) +} + +fn desktop_terminal_settings_path() -> Option { + claude_config_dir().map(|path| path.join("settings.json")) +} + +fn read_desktop_terminal_config() -> Option { + let path = desktop_terminal_settings_path()?; + let contents = fs::read_to_string(path).ok()?; + let settings = serde_json::from_str::(&contents).ok()?; + settings.desktop_terminal +} + +fn resolved_terminal_shell() -> Result { + let system_default = default_shell(); + let platform = current_terminal_host_platform(); + let configured = read_desktop_terminal_config(); + let override_shell = + resolve_desktop_terminal_shell(platform, configured.as_ref(), &system_default)?; + Ok(override_shell.unwrap_or(system_default)) +} + +fn current_terminal_host_platform() -> TerminalHostPlatform { + #[cfg(target_os = "windows")] + { + TerminalHostPlatform::Windows + } + #[cfg(not(target_os = "windows"))] + { + TerminalHostPlatform::Posix + } +} + +fn resolve_desktop_terminal_shell( + platform: TerminalHostPlatform, + config: Option<&DesktopTerminalConfig>, + _system_default: &str, +) -> Result, String> { + if platform != TerminalHostPlatform::Windows { + return Ok(None); + } + + let Some(config) = config else { + return Ok(None); + }; + + let Some(startup_shell) = config.startup_shell.as_deref().map(str::trim) else { + return Ok(None); + }; + + match startup_shell { + "" | "system" => Ok(None), + "pwsh" => Ok(Some("pwsh.exe".to_string())), + "powershell" => Ok(Some("powershell.exe".to_string())), + "cmd" => Ok(Some("cmd.exe".to_string())), + "custom" => { + let path = config + .custom_shell_path + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .ok_or_else(|| "custom terminal shell path is empty".to_string())?; + Ok(Some(path.to_string())) + } + _ => Ok(None), + } +} + fn default_shell() -> String { #[cfg(target_os = "windows")] { @@ -1417,7 +1508,8 @@ mod tests { use super::{ decode_terminal_output, default_utf8_locale, ensure_utf8_locale, has_meaningful_intersection, is_persistable_window_state, parse_env_block, - run_notification_bridge, select_h5_dist_dir, StoredWindowState, SERVER_BIND_HOST, + resolve_desktop_terminal_shell, run_notification_bridge, select_h5_dist_dir, + DesktopTerminalConfig, StoredWindowState, TerminalHostPlatform, SERVER_BIND_HOST, SERVER_CONTROL_HOST, }; use std::{collections::HashMap, fs}; @@ -1557,6 +1649,50 @@ mod tests { assert_eq!(env.get("LC_ALL").map(String::as_str), Some("C.UTF-8")); } + #[test] + fn desktop_terminal_shell_resolution_keeps_system_default_without_preference() { + assert_eq!( + resolve_desktop_terminal_shell( + TerminalHostPlatform::Windows, + None, + "powershell.exe", + ) + .expect("resolution should succeed"), + None + ); + } + + #[test] + fn desktop_terminal_shell_resolution_supports_windows_pwsh_and_custom_path() { + let pwsh = DesktopTerminalConfig { + startup_shell: Some("pwsh".to_string()), + custom_shell_path: None, + }; + assert_eq!( + resolve_desktop_terminal_shell( + TerminalHostPlatform::Windows, + Some(&pwsh), + "powershell.exe", + ) + .expect("pwsh resolution should succeed"), + Some("pwsh.exe".to_string()) + ); + + let custom = DesktopTerminalConfig { + startup_shell: Some("custom".to_string()), + custom_shell_path: Some("/tmp/custom-shell".to_string()), + }; + assert_eq!( + resolve_desktop_terminal_shell( + TerminalHostPlatform::Windows, + Some(&custom), + "powershell.exe", + ) + .expect("custom resolution should succeed"), + Some("/tmp/custom-shell".to_string()) + ); + } + #[test] fn server_sidecar_binds_lan_but_reports_loopback_control_url() { assert_eq!(SERVER_BIND_HOST, "0.0.0.0"); diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index 653397a0..235dce8a 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -181,6 +181,25 @@ export const en = { 'settings.terminal.windowTitle': 'Host shell', 'settings.terminal.unavailableTitle': 'Desktop runtime required', 'settings.terminal.unavailableBody': 'Open this page in the packaged desktop app to start an interactive terminal.', + 'settings.terminal.preferencesTitle': 'Startup shell', + 'settings.terminal.preferencesBody': 'Use for new terminal sessions and after restart.', + 'settings.terminal.startupShell': 'Startup shell', + 'settings.terminal.customPath': 'Custom shell path', + 'settings.terminal.customPathPlaceholder': 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + 'settings.terminal.customPathRequired': 'Enter a shell path before saving.', + 'settings.terminal.customPathAbsolute': 'Custom shell path must be an absolute Windows path.', + 'settings.terminal.saveShell': 'Save shell', + 'settings.terminal.saveShellSuccess': 'Saved. Restart or open a new terminal to apply it.', + 'settings.terminal.shell.system': 'System default', + 'settings.terminal.shell.systemDesc': 'Keep the current COMSPEC-based behavior.', + 'settings.terminal.shell.pwsh': 'PowerShell 7 (pwsh)', + 'settings.terminal.shell.pwshDesc': 'Use pwsh.exe when it is installed.', + 'settings.terminal.shell.powershell': 'Windows PowerShell', + 'settings.terminal.shell.powershellDesc': 'Use the built-in powershell.exe.', + 'settings.terminal.shell.cmd': 'Command Prompt', + 'settings.terminal.shell.cmdDesc': 'Use cmd.exe explicitly.', + 'settings.terminal.shell.custom': 'Custom executable', + 'settings.terminal.shell.customDesc': 'Launch an absolute shell executable path.', 'settings.terminal.status.idle': 'Idle', 'settings.terminal.status.starting': 'Starting', 'settings.terminal.status.running': 'Running', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 8c275bee..750cfd9f 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -183,6 +183,25 @@ export const zh: Record = { 'settings.terminal.windowTitle': '宿主机 Shell', 'settings.terminal.unavailableTitle': '需要桌面端运行时', 'settings.terminal.unavailableBody': '请在打包后的桌面端里打开这个页面,才能启动交互式终端。', + 'settings.terminal.preferencesTitle': '启动 Shell', + 'settings.terminal.preferencesBody': '用于新开的终端会话,以及点击重启后的终端。', + 'settings.terminal.startupShell': '启动 Shell', + 'settings.terminal.customPath': '自定义 Shell 路径', + 'settings.terminal.customPathPlaceholder': 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + 'settings.terminal.customPathRequired': '保存前请先填写 Shell 路径。', + 'settings.terminal.customPathAbsolute': '自定义 Shell 路径必须是绝对 Windows 路径。', + 'settings.terminal.saveShell': '保存 Shell', + 'settings.terminal.saveShellSuccess': '已保存。重启终端或新开终端后生效。', + 'settings.terminal.shell.system': '系统默认', + 'settings.terminal.shell.systemDesc': '保持当前基于 COMSPEC 的行为。', + 'settings.terminal.shell.pwsh': 'PowerShell 7 (pwsh)', + 'settings.terminal.shell.pwshDesc': '安装了 pwsh.exe 时优先使用它。', + 'settings.terminal.shell.powershell': 'Windows PowerShell', + 'settings.terminal.shell.powershellDesc': '使用系统自带的 powershell.exe。', + 'settings.terminal.shell.cmd': '命令提示符', + 'settings.terminal.shell.cmdDesc': '明确使用 cmd.exe。', + 'settings.terminal.shell.custom': '自定义可执行文件', + 'settings.terminal.shell.customDesc': '启动一个绝对路径的 Shell 可执行文件。', 'settings.terminal.status.idle': '空闲', 'settings.terminal.status.starting': '启动中', 'settings.terminal.status.running': '运行中', diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 1b94b5c8..e6599d74 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -112,7 +112,7 @@ export function Settings() { {activeTab === 'general' && } {activeTab === 'h5Access' && } {activeTab === 'adapters' && } - {activeTab === 'terminal' && } + {activeTab === 'terminal' && } {activeTab === 'mcp' && } {activeTab === 'agents' && } {activeTab === 'skills' && } diff --git a/desktop/src/pages/TerminalSettings.test.tsx b/desktop/src/pages/TerminalSettings.test.tsx index a2a68c31..b674cbc7 100644 --- a/desktop/src/pages/TerminalSettings.test.tsx +++ b/desktop/src/pages/TerminalSettings.test.tsx @@ -56,6 +56,13 @@ import { TerminalSettings } from './TerminalSettings' describe('TerminalSettings', () => { beforeEach(() => { useSettingsStore.setState({ locale: 'en' }) + useSettingsStore.setState({ + desktopTerminal: { + startupShell: 'system', + customShellPath: '', + }, + setDesktopTerminal: vi.fn().mockResolvedValue(undefined), + }) terminalMocks.available = false terminalMocks.spawn.mockReset() terminalMocks.write.mockReset() @@ -142,4 +149,17 @@ describe('TerminalSettings', () => { expect(terminalMocks.terminalInstance.write).toHaveBeenCalledWith('hello\r\n') expect(terminalMocks.terminalInstance.write).not.toHaveBeenCalledWith('ignored\r\n') }) + + it('shows Windows-only startup shell controls in settings mode', () => { + vi.stubGlobal('navigator', { + ...navigator, + platform: 'Win32', + userAgent: 'Windows', + }) + + render() + + expect(screen.getAllByText('Startup shell')).toHaveLength(2) + expect(screen.getByText('Use for new terminal sessions and after restart.')).toBeInTheDocument() + }) }) diff --git a/desktop/src/pages/TerminalSettings.tsx b/desktop/src/pages/TerminalSettings.tsx index ff623074..82309c44 100644 --- a/desktop/src/pages/TerminalSettings.tsx +++ b/desktop/src/pages/TerminalSettings.tsx @@ -1,8 +1,13 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { Terminal as XTermTerminal } from '@xterm/xterm' import type { FitAddon as XTermFitAddon } from '@xterm/addon-fit' import { useTranslation, type TranslationKey } from '../i18n' import { terminalApi } from '../api/terminal' +import { useSettingsStore } from '../stores/settingsStore' +import { Dropdown } from '../components/shared/Dropdown' +import { Input } from '../components/shared/Input' +import { Button } from '../components/shared/Button' +import type { DesktopTerminalStartupShell } from '../types/settings' type TerminalStatus = 'idle' | 'starting' | 'running' | 'exited' | 'error' | 'unavailable' @@ -24,6 +29,7 @@ type TerminalSettingsProps = { testId?: string workspace?: boolean docked?: boolean + showPreferences?: boolean } export function TerminalSettings({ @@ -35,8 +41,11 @@ export function TerminalSettings({ testId = 'settings-terminal-host', workspace = false, docked = false, + showPreferences = false, }: TerminalSettingsProps = {}) { const t = useTranslation() + const desktopTerminal = useSettingsStore((state) => state.desktopTerminal) + const setDesktopTerminal = useSettingsStore((state) => state.setDesktopTerminal) const hostRef = useRef(null) const terminalRef = useRef(null) const fitRef = useRef(null) @@ -45,6 +54,51 @@ export function TerminalSettings({ const [status, setStatus] = useState(() => terminalApi.isAvailable() ? 'idle' : 'unavailable') const [error, setError] = useState(null) const [shellInfo, setShellInfo] = useState<{ shell: string; cwd: string } | null>(null) + const [startupShell, setStartupShell] = useState(desktopTerminal?.startupShell ?? 'system') + const [customShellPath, setCustomShellPath] = useState(desktopTerminal?.customShellPath ?? '') + const [preferencesError, setPreferencesError] = useState(null) + const [preferencesSaved, setPreferencesSaved] = useState(false) + const [preferencesSaving, setPreferencesSaving] = useState(false) + const isWindows = typeof navigator !== 'undefined' && /Win/i.test(navigator.platform || navigator.userAgent) + + useEffect(() => { + setStartupShell(desktopTerminal?.startupShell ?? 'system') + setCustomShellPath(desktopTerminal?.customShellPath ?? '') + }, [desktopTerminal]) + + useEffect(() => { + if (!preferencesSaved) return + const timer = window.setTimeout(() => setPreferencesSaved(false), 2500) + return () => window.clearTimeout(timer) + }, [preferencesSaved]) + + const shellItems = useMemo(() => [ + { + value: 'system' as const, + label: t('settings.terminal.shell.system'), + description: t('settings.terminal.shell.systemDesc'), + }, + { + value: 'pwsh' as const, + label: t('settings.terminal.shell.pwsh'), + description: t('settings.terminal.shell.pwshDesc'), + }, + { + value: 'powershell' as const, + label: t('settings.terminal.shell.powershell'), + description: t('settings.terminal.shell.powershellDesc'), + }, + { + value: 'cmd' as const, + label: t('settings.terminal.shell.cmd'), + description: t('settings.terminal.shell.cmdDesc'), + }, + { + value: 'custom' as const, + label: t('settings.terminal.shell.custom'), + description: t('settings.terminal.shell.customDesc'), + }, + ], [t]) const resizeSession = useCallback(() => { const terminal = terminalRef.current @@ -202,6 +256,36 @@ export function TerminalSettings({ terminalRef.current?.clear() } + const savePreferences = async () => { + setPreferencesError(null) + setPreferencesSaved(false) + + const trimmedPath = customShellPath.trim() + if (startupShell === 'custom') { + if (!trimmedPath) { + setPreferencesError(t('settings.terminal.customPathRequired')) + return + } + if (!/^[A-Za-z]:[\\/]/.test(trimmedPath)) { + setPreferencesError(t('settings.terminal.customPathAbsolute')) + return + } + } + + setPreferencesSaving(true) + try { + await setDesktopTerminal({ + startupShell, + customShellPath: trimmedPath, + }) + setPreferencesSaved(true) + } catch (err) { + setPreferencesError(err instanceof Error ? err.message : String(err)) + } finally { + setPreferencesSaving(false) + } + } + return (
)} + {showPreferences && isWindows && ( +
+
+
+

+ {t('settings.terminal.preferencesTitle')} +

+

+ {t('settings.terminal.preferencesBody')} +

+
+ +
+ + {t('settings.terminal.startupShell')} + + + items={shellItems} + value={startupShell} + onChange={(value) => { + setStartupShell(value) + setPreferencesError(null) + setPreferencesSaved(false) + }} + width="100%" + trigger={ + + } + /> +
+ + {startupShell === 'custom' && ( + { + setCustomShellPath(event.target.value) + setPreferencesError(null) + setPreferencesSaved(false) + }} + error={preferencesError ?? undefined} + /> + )} + + {preferencesError && startupShell !== 'custom' && ( +

{preferencesError}

+ )} + +
+ + {preferencesSaved && ( + + {t('settings.terminal.saveShellSuccess')} + + )} +
+
+
+ )} + {status === 'unavailable' ? (
diff --git a/desktop/src/stores/settingsStore.test.ts b/desktop/src/stores/settingsStore.test.ts index 8c45f172..d57a27d7 100644 --- a/desktop/src/stores/settingsStore.test.ts +++ b/desktop/src/stores/settingsStore.test.ts @@ -305,6 +305,127 @@ describe('settingsStore thinking persistence', () => { }) }) +describe('settingsStore desktop terminal shell persistence', () => { + beforeEach(() => { + vi.resetModules() + vi.clearAllMocks() + window.localStorage.clear() + }) + + it('hydrates desktop terminal settings from user settings and falls back to system defaults', async () => { + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser: vi.fn().mockResolvedValue({ + desktopTerminal: { + startupShell: 'pwsh', + customShellPath: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + }, + }), + updateUser: vi.fn(), + getPermissionMode: vi.fn().mockResolvedValue({ mode: 'default' }), + setPermissionMode: vi.fn(), + getCliLauncherStatus: vi.fn(), + }, + })) + vi.doMock('../api/models', () => ({ + modelsApi: { + list: vi.fn().mockResolvedValue({ models: [] }), + getCurrent: vi.fn().mockResolvedValue({ model: null }), + setCurrent: vi.fn(), + getEffort: vi.fn().mockResolvedValue({ level: 'medium' }), + setEffort: vi.fn(), + }, + })) + vi.doMock('../api/h5Access', () => ({ + h5AccessApi: { + get: vi.fn().mockResolvedValue({ + settings: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }, + }), + enable: vi.fn(), + disable: vi.fn(), + regenerate: vi.fn(), + update: vi.fn(), + }, + })) + + const { useSettingsStore } = await import('./settingsStore') + + expect(useSettingsStore.getState().desktopTerminal).toEqual({ + startupShell: 'system', + customShellPath: '', + }) + + await useSettingsStore.getState().fetchAll() + + expect(useSettingsStore.getState().desktopTerminal).toEqual({ + startupShell: 'pwsh', + customShellPath: 'C:\\Program Files\\PowerShell\\7\\pwsh.exe', + }) + }) + + it('persists desktop terminal settings explicitly', async () => { + const updateUser = vi.fn().mockResolvedValue({ ok: true }) + + vi.doMock('../api/settings', () => ({ + settingsApi: { + getUser: vi.fn(), + updateUser, + getPermissionMode: vi.fn(), + setPermissionMode: vi.fn(), + getCliLauncherStatus: vi.fn(), + }, + })) + vi.doMock('../api/models', () => ({ + modelsApi: { + list: vi.fn(), + getCurrent: vi.fn(), + setCurrent: vi.fn(), + getEffort: vi.fn(), + setEffort: vi.fn(), + }, + })) + vi.doMock('../api/h5Access', () => ({ + h5AccessApi: { + get: vi.fn().mockResolvedValue({ + settings: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }, + }), + enable: vi.fn(), + disable: vi.fn(), + regenerate: vi.fn(), + update: vi.fn(), + }, + })) + + const { useSettingsStore } = await import('./settingsStore') + + await useSettingsStore.getState().setDesktopTerminal({ + startupShell: 'custom', + customShellPath: 'C:\\tools\\pwsh.exe', + }) + + expect(updateUser).toHaveBeenCalledWith({ + desktopTerminal: { + startupShell: 'custom', + customShellPath: 'C:\\tools\\pwsh.exe', + }, + }) + expect(useSettingsStore.getState().desktopTerminal).toEqual({ + startupShell: 'custom', + customShellPath: 'C:\\tools\\pwsh.exe', + }) + }) +}) + describe('settingsStore theme persistence', () => { beforeEach(() => { vi.resetModules() diff --git a/desktop/src/stores/settingsStore.ts b/desktop/src/stores/settingsStore.ts index 51e628ab..e0d10d99 100644 --- a/desktop/src/stores/settingsStore.ts +++ b/desktop/src/stores/settingsStore.ts @@ -3,7 +3,17 @@ import { ApiError } from '../api/client' import { settingsApi } from '../api/settings' import { modelsApi } from '../api/models' import { h5AccessApi } from '../api/h5Access' -import { isThemeMode, type H5AccessSettings, type PermissionMode, type EffortLevel, type ModelInfo, type ThemeMode, type WebSearchSettings } from '../types/settings' +import { + isThemeMode, + type DesktopTerminalSettings, + type DesktopTerminalStartupShell, + type H5AccessSettings, + type PermissionMode, + type EffortLevel, + type ModelInfo, + type ThemeMode, + type WebSearchSettings, +} from '../types/settings' import type { Locale } from '../i18n' import { APP_ZOOM_CONTROL_STEP, @@ -42,6 +52,7 @@ type SettingsStore = { theme: ThemeMode skipWebFetchPreflight: boolean desktopNotificationsEnabled: boolean + desktopTerminal: DesktopTerminalSettings webSearch: WebSearchSettings h5Access: H5AccessSettings h5AccessError: string | null @@ -60,6 +71,7 @@ type SettingsStore = { setTheme: (theme: ThemeMode) => Promise setSkipWebFetchPreflight: (enabled: boolean) => Promise setDesktopNotificationsEnabled: (enabled: boolean) => Promise + setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise setWebSearch: (settings: WebSearchSettings) => Promise enableH5Access: () => Promise disableH5Access: () => Promise @@ -79,6 +91,11 @@ const DEFAULT_H5_ACCESS_SETTINGS: H5AccessSettings = { publicBaseUrl: null, } +const DEFAULT_DESKTOP_TERMINAL_SETTINGS: DesktopTerminalSettings = { + startupShell: 'system', + customShellPath: '', +} + export const useSettingsStore = create((set, get) => ({ permissionMode: 'default', currentModel: null, @@ -90,6 +107,7 @@ export const useSettingsStore = create((set, get) => ({ theme: useUIStore.getState().theme, skipWebFetchPreflight: true, desktopNotificationsEnabled: false, + desktopTerminal: DEFAULT_DESKTOP_TERMINAL_SETTINGS, webSearch: { mode: 'auto', tavilyApiKey: '', braveApiKey: '' }, h5Access: DEFAULT_H5_ACCESS_SETTINGS, h5AccessError: null, @@ -128,6 +146,7 @@ export const useSettingsStore = create((set, get) => ({ theme, skipWebFetchPreflight: userSettings.skipWebFetchPreflight !== false, desktopNotificationsEnabled: userSettings.desktopNotificationsEnabled === true, + desktopTerminal: normalizeDesktopTerminalSettings(userSettings.desktopTerminal), webSearch: normalizeWebSearchSettings(userSettings.webSearch), h5Access: h5AccessResult.settings, h5AccessError: h5AccessResult.error, @@ -232,6 +251,18 @@ export const useSettingsStore = create((set, get) => ({ } }, + setDesktopTerminal: async (settings) => { + const prev = get().desktopTerminal + const next = normalizeDesktopTerminalSettings(settings) + set({ desktopTerminal: next }) + try { + await settingsApi.updateUser({ desktopTerminal: next }) + } catch (error) { + set({ desktopTerminal: prev }) + throw error + } + }, + setWebSearch: async (webSearch) => { const prev = get().webSearch const next = normalizeWebSearchSettings(webSearch) @@ -320,6 +351,21 @@ function normalizeWebSearchSettings(settings: WebSearchSettings | undefined): We } } +function normalizeDesktopTerminalSettings( + settings: Partial | undefined, +): DesktopTerminalSettings { + const startupShell = isDesktopTerminalStartupShell(settings?.startupShell) + ? settings.startupShell + : DEFAULT_DESKTOP_TERMINAL_SETTINGS.startupShell + + return { + startupShell, + customShellPath: typeof settings?.customShellPath === 'string' + ? settings.customShellPath + : DEFAULT_DESKTOP_TERMINAL_SETTINGS.customShellPath, + } +} + function normalizeH5AccessSettings(settings: H5AccessSettings | undefined): H5AccessSettings { return { enabled: settings?.enabled === true, @@ -366,3 +412,11 @@ function isLegacyH5EndpointError(error: unknown) { function getErrorMessage(error: unknown, fallback: string) { return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback } + +function isDesktopTerminalStartupShell(value: unknown): value is DesktopTerminalStartupShell { + return value === 'system' + || value === 'pwsh' + || value === 'powershell' + || value === 'cmd' + || value === 'custom' +} diff --git a/desktop/src/types/settings.ts b/desktop/src/types/settings.ts index d5ea3aa8..4b1d985b 100644 --- a/desktop/src/types/settings.ts +++ b/desktop/src/types/settings.ts @@ -25,6 +25,18 @@ export type H5AccessSettings = { publicBaseUrl: string | null } +export type DesktopTerminalStartupShell = + | 'system' + | 'pwsh' + | 'powershell' + | 'cmd' + | 'custom' + +export type DesktopTerminalSettings = { + startupShell: DesktopTerminalStartupShell + customShellPath: string +} + export type ModelInfo = { id: string name: string @@ -43,5 +55,6 @@ export type UserSettings = { desktopNotificationsEnabled?: boolean webSearch?: WebSearchSettings language?: string + desktopTerminal?: Partial [key: string]: unknown }