import { useCallback, useEffect, useId, useMemo, useRef, useState, type KeyboardEvent, type WheelEvent } from 'react' import { Info } from 'lucide-react' 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' import { getDesktopHost } from '../lib/desktopHost' import { attachTerminalRuntime, createLocalTerminalRuntimeId, destroyTerminalRuntime, getTerminalRuntime, isTerminalRuntimeCurrent, subscribeTerminalRuntime, updateTerminalRuntime, type TerminalRuntime, type TerminalStatus, } from '../lib/terminalRuntime' const STATUS_LABEL_KEYS: Record = { idle: 'settings.terminal.status.idle', starting: 'settings.terminal.status.starting', running: 'settings.terminal.status.running', exited: 'settings.terminal.status.exited', error: 'settings.terminal.status.error', unavailable: 'settings.terminal.status.unavailable', } function findScrollableAncestor(element: HTMLElement, deltaY: number): HTMLElement | null { let parent = element.parentElement while (parent) { const style = window.getComputedStyle(parent) const canScrollY = style.overflowY === 'auto' || style.overflowY === 'scroll' if (canScrollY && parent.scrollHeight > parent.clientHeight) { const maxScrollTop = parent.scrollHeight - parent.clientHeight const canMove = deltaY < 0 ? parent.scrollTop > 0 : parent.scrollTop < maxScrollTop if (canMove) return parent } parent = parent.parentElement } return null } type TerminalSettingsProps = { active?: boolean cwd?: string onNewTerminal?: () => void onOpenInTab?: () => void onClose?: () => void testId?: string workspace?: boolean docked?: boolean showPreferences?: boolean runtimeId?: string preserveOnUnmount?: boolean } export function TerminalSettings({ active = true, cwd, onNewTerminal, onOpenInTab, onClose, testId = 'settings-terminal-host', workspace = false, docked = false, showPreferences = false, runtimeId, preserveOnUnmount = false, }: TerminalSettingsProps = {}) { const t = useTranslation() const desktopTerminal = useSettingsStore((state) => state.desktopTerminal) const setDesktopTerminal = useSettingsStore((state) => state.setDesktopTerminal) const hostRef = useRef(null) const localRuntimeIdRef = useRef(null) if (!localRuntimeIdRef.current) { localRuntimeIdRef.current = runtimeId ?? createLocalTerminalRuntimeId() } const effectiveRuntimeId = runtimeId ?? localRuntimeIdRef.current const runtimeRef = useRef(null) if (!runtimeRef.current || runtimeRef.current.id !== effectiveRuntimeId) { runtimeRef.current = getTerminalRuntime(effectiveRuntimeId, terminalApi.isAvailable() ? 'idle' : 'unavailable') } const runtime = runtimeRef.current const [, forceRuntimeUpdate] = useState(0) const status = runtime.status const error = runtime.error const shellInfo = runtime.shellInfo 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(() => { return subscribeTerminalRuntime(runtime, () => forceRuntimeUpdate((value) => value + 1)) }, [runtime]) 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 = runtime.terminal const fit = runtime.fit const sessionId = runtime.nativeSessionId if (!terminal || !fit) return fit.fit() if (sessionId) { void terminalApi.resize(sessionId, terminal.cols, terminal.rows).catch(() => {}) } }, [runtime]) const startTerminal = useCallback(() => { if (!terminalApi.isAvailable()) { updateTerminalRuntime(runtime, { status: 'unavailable' }) return Promise.resolve() } if (runtime.startPromise) { const host = hostRef.current void runtime.startPromise.then(() => { if (!host || !isTerminalRuntimeCurrent(runtime) || !runtime.terminal) return attachTerminalRuntime(runtime, host) resizeSession() }) return runtime.startPromise } const host = hostRef.current if (!host) return Promise.resolve() const startToken = runtime.startToken + 1 runtime.startToken = startToken const isCurrentStart = () => isTerminalRuntimeCurrent(runtime) && runtime.startToken === startToken const startPromise = Promise.resolve().then(async () => { if (!isCurrentStart()) return updateTerminalRuntime(runtime, { error: null, status: 'starting', shellInfo: null }) const existing = runtime.nativeSessionId if (existing) { await terminalApi.kill(existing).catch(() => {}) if (!isCurrentStart()) return runtime.nativeSessionId = null } runtime.dataDisposable?.dispose() runtime.dataDisposable = null runtime.unlisteners.forEach((unlisten) => unlisten()) runtime.unlisteners = [] runtime.terminal?.dispose() runtime.terminal = null runtime.fit = null host.innerHTML = '' let TerminalModule: typeof import('@xterm/xterm') let FitAddonModule: typeof import('@xterm/addon-fit') try { [TerminalModule, FitAddonModule] = await Promise.all([ import('@xterm/xterm'), import('@xterm/addon-fit'), ]) } catch (err) { if (isCurrentStart()) { updateTerminalRuntime(runtime, { error: err instanceof Error ? err.message : String(err), status: 'error', }) } return } if (!isCurrentStart()) return let terminal: import('@xterm/xterm').Terminal | null = null let fit: import('@xterm/addon-fit').FitAddon | null = null let outputUnlisten: (() => void) | null = null let exitUnlisten: (() => void) | null = null try { terminal = new TerminalModule.Terminal({ cursorBlink: true, convertEol: false, fontFamily: "var(--font-mono), 'SFMono-Regular', Consolas, monospace", fontSize: 12, lineHeight: 1.25, scrollback: 4000, theme: { background: '#121212', foreground: '#d7d2d0', cursor: '#ffb59f', selectionBackground: '#5f4a40', black: '#1f1f1f', red: '#ff6d67', green: '#7ef18a', yellow: '#f8c55f', blue: '#77a8ff', magenta: '#d699ff', cyan: '#61d6d6', white: '#d7d2d0', brightBlack: '#8f8683', brightRed: '#ff8a85', brightGreen: '#9ff7a7', brightYellow: '#ffdd7a', brightBlue: '#a6c5ff', brightMagenta: '#e3b8ff', brightCyan: '#8ceeee', brightWhite: '#ffffff', }, }) fit = new FitAddonModule.FitAddon() const activeTerminal = terminal const activeFit = fit activeTerminal.loadAddon(activeFit) activeTerminal.open(host) if (!isCurrentStart()) { activeTerminal.dispose() return } updateTerminalRuntime(runtime, { terminal: activeTerminal, fit: activeFit }) activeFit.fit() outputUnlisten = await terminalApi.onOutput((payload) => { if (payload.session_id === runtime.nativeSessionId) { activeTerminal.write(payload.data) } }) exitUnlisten = await terminalApi.onExit((payload) => { if (payload.session_id !== runtime.nativeSessionId) return updateTerminalRuntime(runtime, { status: 'exited' }) const signal = payload.signal ? `, ${payload.signal}` : '' activeTerminal.writeln(`\r\n[process exited: ${payload.code}${signal}]`) updateTerminalRuntime(runtime, { nativeSessionId: null }) }) if (!isCurrentStart()) { outputUnlisten() exitUnlisten() activeTerminal.dispose() return } runtime.unlisteners = [outputUnlisten, exitUnlisten] runtime.dataDisposable = terminal.onData((data) => { const sessionId = runtime.nativeSessionId if (sessionId) { void terminalApi.write(sessionId, data).catch((err) => { updateTerminalRuntime(runtime, { error: err instanceof Error ? err.message : String(err), status: 'error', }) }) } }) const result = await terminalApi.spawn({ cols: activeTerminal.cols, rows: activeTerminal.rows, ...(cwd ? { cwd } : {}), }) if (!isCurrentStart()) { await terminalApi.kill(result.session_id).catch(() => {}) outputUnlisten() exitUnlisten() activeTerminal.dispose() return } updateTerminalRuntime(runtime, { nativeSessionId: result.session_id, shellInfo: { shell: result.shell, cwd: result.cwd }, status: 'running', }) resizeSession() } catch (err) { outputUnlisten?.() exitUnlisten?.() terminal?.dispose() if (isCurrentStart()) { updateTerminalRuntime(runtime, { terminal: null, fit: null, error: err instanceof Error ? err.message : String(err), status: 'error', }) } } }) runtime.startPromise = startPromise void startPromise.finally(() => { if (runtime.startPromise === startPromise) { runtime.startPromise = null } }).catch(() => {}) return startPromise }, [cwd, resizeSession, runtime]) useEffect(() => { if (!terminalApi.isAvailable()) return if (runtime.terminal) { if (hostRef.current) { attachTerminalRuntime(runtime, hostRef.current) } resizeSession() } else if (runtime.startPromise) { void runtime.startPromise.then(() => { if (!hostRef.current || !isTerminalRuntimeCurrent(runtime) || !runtime.terminal) return attachTerminalRuntime(runtime, hostRef.current) resizeSession() }) } else { void startTerminal() } const observer = new ResizeObserver(() => resizeSession()) if (hostRef.current) observer.observe(hostRef.current) return () => { observer.disconnect() if (!preserveOnUnmount) { destroyTerminalRuntime(runtime.id) } } }, [preserveOnUnmount, resizeSession, runtime, startTerminal]) useEffect(() => { if (active) { requestAnimationFrame(() => resizeSession()) } }, [active, resizeSession]) const clearTerminal = () => { runtime.terminal?.clear() } const handleTerminalWheelCapture = useCallback((event: WheelEvent) => { const host = hostRef.current if (!host || host.contains(document.activeElement)) return const scroller = findScrollableAncestor(event.currentTarget, event.deltaY) if (!scroller) return event.preventDefault() event.stopPropagation() scroller.scrollBy({ top: event.deltaY, left: event.deltaX }) }, []) const handleTerminalKeyDownCapture = useCallback((event: KeyboardEvent) => { const terminal = runtime.terminal if (!terminal) return if (isTerminalCopyShortcut(event, terminal)) { event.preventDefault() event.stopPropagation() void copyTerminalSelection(terminal).catch(() => {}) return } if (isTerminalPasteShortcut(event)) { event.preventDefault() event.stopPropagation() void pasteClipboardIntoTerminal(terminal).catch(() => {}) } }, [runtime]) 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 (
{onOpenInTab && ( )} {onNewTerminal && ( )} {onClose && ( )}
{error && (
{error}
)} {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' ? (
desktop_windows

{t('settings.terminal.unavailableTitle')}

{t('settings.terminal.unavailableBody')}

) : (
)}
) } type TerminalKeyboardEvent = Pick, 'altKey' | 'ctrlKey' | 'key' | 'metaKey' | 'shiftKey'> type ClipboardTerminal = { focus(): void getSelection(): string hasSelection(): boolean paste(data: string): void } function isApplePlatform() { if (typeof navigator === 'undefined') return false return /Mac|iPhone|iPad|iPod/i.test(navigator.platform) } function isWindowsPlatform() { if (typeof navigator === 'undefined') return false return /Win/i.test(navigator.platform || navigator.userAgent) } function normalizedKey(event: TerminalKeyboardEvent) { return event.key.toLowerCase() } function isTerminalCopyShortcut(event: TerminalKeyboardEvent, terminal: ClipboardTerminal) { if (event.altKey || !terminal.hasSelection()) return false const key = normalizedKey(event) if (isApplePlatform()) { return event.metaKey && !event.ctrlKey && key === 'c' } if (key === 'insert') { return event.ctrlKey && !event.shiftKey && !event.metaKey } if (isWindowsPlatform() && event.ctrlKey && !event.metaKey && !event.shiftKey && key === 'c') { return true } return event.ctrlKey && !event.metaKey && event.shiftKey && key === 'c' } function isTerminalPasteShortcut(event: TerminalKeyboardEvent) { if (event.altKey) return false const key = normalizedKey(event) if (isApplePlatform()) { return event.metaKey && !event.ctrlKey && key === 'v' } if (key === 'insert') { return event.shiftKey && !event.ctrlKey && !event.metaKey } if (isWindowsPlatform() && event.ctrlKey && !event.metaKey && !event.shiftKey && key === 'v') { return true } return event.ctrlKey && !event.metaKey && event.shiftKey && key === 'v' } async function copyTerminalSelection(terminal: ClipboardTerminal) { const text = terminal.getSelection() if (!text) return await getDesktopHost().clipboard.writeText(text) terminal.focus() } async function pasteClipboardIntoTerminal(terminal: ClipboardTerminal) { const text = await getDesktopHost().clipboard.readText() if (!text) return terminal.paste(text) terminal.focus() } function TerminalHelpHint({ compact = false }: { compact?: boolean }) { const t = useTranslation() const tooltipId = useId() const [open, setOpen] = useState(false) return ( {t('settings.terminal.description')} ) } function StatusPill({ status, label, compact = false }: { status: TerminalStatus; label: string; compact?: boolean }) { const color = status === 'running' ? 'bg-[var(--color-success)]' : status === 'error' ? 'bg-[var(--color-error)]' : status === 'starting' ? 'bg-[var(--color-warning)]' : 'bg-[var(--color-text-tertiary)]' return ( {label} ) } function BashPathSettings({ isTauri }: { isTauri: boolean }) { const t = useTranslation() const [bashPath, setBashPath] = useState(null) const [saving, setSaving] = useState(false) const [saved, setSaved] = useState(false) const [invalid, setInvalid] = useState(false) useEffect(() => { if (!isTauri) return void terminalApi.getBashPath().then((path) => setBashPath(path)).catch(() => {}) }, [isTauri]) const handleSave = async () => { const trimmed = bashPath?.trim() || null setSaving(true) setInvalid(false) setSaved(false) try { await terminalApi.setBashPath(trimmed) setBashPath(trimmed) setSaved(true) setTimeout(() => setSaved(false), 2000) } catch { setInvalid(true) } finally { setSaving(false) } } const handleReset = async () => { setSaving(true) setSaved(false) setInvalid(false) try { await terminalApi.setBashPath(null) setBashPath(null) setSaved(true) setTimeout(() => setSaved(false), 2000) } catch { // ignore } finally { setSaving(false) } } const handleBrowse = async () => { if (!isTauri) return const host = getDesktopHost() if (!host.capabilities.dialogs) return try { const selected = await host.dialogs.open({ title: t('settings.terminal.bashPathLabel'), multiple: false, filters: [{ name: 'Bash Executable', extensions: ['exe', '', 'bat', 'cmd', 'ps1'], }], }) if (selected && typeof selected === 'string') { setBashPath(selected) setInvalid(false) } } catch { // user cancelled } } if (!isTauri) return null return (

{t('settings.terminal.bashPathDescription')}

{ setBashPath(e.target.value); setInvalid(false); setSaved(false) }} placeholder={t('settings.terminal.bashPathLabel')} className="flex-1 rounded-[var(--radius-sm)] border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-1.5 text-sm font-mono text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-tertiary)] focus:border-[var(--color-border-focus)]" />
{invalid && (

{t('settings.terminal.bashPathInvalid')}

)}
) }