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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-17 18:21:13 +08:00
parent a4c92ec785
commit 8ff5353455
9 changed files with 545 additions and 5 deletions

View File

@ -297,6 +297,25 @@ struct TerminalExitPayload {
signal: Option<String>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct DesktopTerminalSettingsFile {
desktop_terminal: Option<DesktopTerminalConfig>,
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
struct DesktopTerminalConfig {
startup_shell: Option<String>,
custom_shell_path: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum TerminalHostPlatform {
Windows,
Posix,
}
#[tauri::command]
fn get_server_url(state: State<'_, ServerState>) -> Result<String, String> {
let guard = state
@ -612,7 +631,7 @@ fn terminal_spawn(
cwd: Option<String>,
) -> Result<TerminalSpawnResult, String> {
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<PathBuf> {
.map(PathBuf::from)
}
fn claude_config_dir() -> Option<PathBuf> {
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<PathBuf> {
claude_config_dir().map(|path| path.join("settings.json"))
}
fn read_desktop_terminal_config() -> Option<DesktopTerminalConfig> {
let path = desktop_terminal_settings_path()?;
let contents = fs::read_to_string(path).ok()?;
let settings = serde_json::from_str::<DesktopTerminalSettingsFile>(&contents).ok()?;
settings.desktop_terminal
}
fn resolved_terminal_shell() -> Result<String, String> {
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<Option<String>, 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");

View File

@ -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',

View File

@ -183,6 +183,25 @@ export const zh: Record<TranslationKey, string> = {
'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': '运行中',

View File

@ -112,7 +112,7 @@ export function Settings() {
{activeTab === 'general' && <GeneralSettings />}
{activeTab === 'h5Access' && <H5AccessSettings />}
{activeTab === 'adapters' && <AdapterSettings />}
{activeTab === 'terminal' && <TerminalSettings />}
{activeTab === 'terminal' && <TerminalSettings showPreferences />}
{activeTab === 'mcp' && <McpSettings />}
{activeTab === 'agents' && <AgentsSettings />}
{activeTab === 'skills' && <SkillSettings />}

View File

@ -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(<TerminalSettings showPreferences />)
expect(screen.getAllByText('Startup shell')).toHaveLength(2)
expect(screen.getByText('Use for new terminal sessions and after restart.')).toBeInTheDocument()
})
})

View File

@ -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<HTMLDivElement | null>(null)
const terminalRef = useRef<XTermTerminal | null>(null)
const fitRef = useRef<XTermFitAddon | null>(null)
@ -45,6 +54,51 @@ export function TerminalSettings({
const [status, setStatus] = useState<TerminalStatus>(() => terminalApi.isAvailable() ? 'idle' : 'unavailable')
const [error, setError] = useState<string | null>(null)
const [shellInfo, setShellInfo] = useState<{ shell: string; cwd: string } | null>(null)
const [startupShell, setStartupShell] = useState<DesktopTerminalStartupShell>(desktopTerminal?.startupShell ?? 'system')
const [customShellPath, setCustomShellPath] = useState(desktopTerminal?.customShellPath ?? '')
const [preferencesError, setPreferencesError] = useState<string | null>(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 (
<div className={`flex h-full flex-col overflow-hidden ${
docked
@ -289,6 +373,80 @@ export function TerminalSettings({
</div>
)}
{showPreferences && isWindows && (
<div className="mb-4 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-4">
<div className="flex flex-col gap-3">
<div>
<h3 className="text-sm font-semibold text-[var(--color-text-primary)]">
{t('settings.terminal.preferencesTitle')}
</h3>
<p className="mt-1 text-sm text-[var(--color-text-secondary)]">
{t('settings.terminal.preferencesBody')}
</p>
</div>
<div className="flex flex-col gap-2">
<span className="text-sm font-medium text-[var(--color-text-primary)]">
{t('settings.terminal.startupShell')}
</span>
<Dropdown<DesktopTerminalStartupShell>
items={shellItems}
value={startupShell}
onChange={(value) => {
setStartupShell(value)
setPreferencesError(null)
setPreferencesSaved(false)
}}
width="100%"
trigger={
<button
type="button"
className="flex h-10 w-full items-center justify-between rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 text-sm text-[var(--color-text-primary)]"
>
<span>{shellItems.find((item) => item.value === startupShell)?.label ?? startupShell}</span>
<span className="material-symbols-outlined text-[18px] text-[var(--color-text-tertiary)]">expand_more</span>
</button>
}
/>
</div>
{startupShell === 'custom' && (
<Input
label={t('settings.terminal.customPath')}
placeholder={t('settings.terminal.customPathPlaceholder')}
value={customShellPath}
onChange={(event) => {
setCustomShellPath(event.target.value)
setPreferencesError(null)
setPreferencesSaved(false)
}}
error={preferencesError ?? undefined}
/>
)}
{preferencesError && startupShell !== 'custom' && (
<p className="text-xs text-[var(--color-error)]">{preferencesError}</p>
)}
<div className="flex flex-wrap items-center gap-3">
<Button
type="button"
size="sm"
loading={preferencesSaving}
onClick={() => void savePreferences()}
>
{t('settings.terminal.saveShell')}
</Button>
{preferencesSaved && (
<span className="text-xs text-[var(--color-text-secondary)]">
{t('settings.terminal.saveShellSuccess')}
</span>
)}
</div>
</div>
</div>
)}
{status === 'unavailable' ? (
<div className="flex flex-1 items-center justify-center rounded-[var(--radius-lg)] border border-dashed border-[var(--color-border)] bg-[var(--color-surface-container-low)] p-8 text-center">
<div>

View File

@ -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()

View File

@ -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<void>
setSkipWebFetchPreflight: (enabled: boolean) => Promise<void>
setDesktopNotificationsEnabled: (enabled: boolean) => Promise<void>
setDesktopTerminal: (settings: DesktopTerminalSettings) => Promise<void>
setWebSearch: (settings: WebSearchSettings) => Promise<void>
enableH5Access: () => Promise<string>
disableH5Access: () => Promise<void>
@ -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<SettingsStore>((set, get) => ({
permissionMode: 'default',
currentModel: null,
@ -90,6 +107,7 @@ export const useSettingsStore = create<SettingsStore>((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<SettingsStore>((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<SettingsStore>((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<DesktopTerminalSettings> | 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'
}

View File

@ -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<DesktopTerminalSettings>
[key: string]: unknown
}