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 }