import React, { useCallback, useEffect, useRef, useState, useMemo } from "react"; import { AlertCircle, Import, Minus, Palette, Pencil, Plus, Trash2 } from "lucide-react"; import type { AutocompleteHistoryScope, CursorShape, HostInfoBarTitleMode, PasswordPromptAssistMode, TerminalEmulationType, TerminalSettings, } from "../../../domain/models"; import { useI18n } from "../../../application/i18n/I18nProvider"; import { MAX_FONT_SIZE, MIN_FONT_SIZE, resolveTerminalFontFamilyId, type TerminalFont } from "../../../infrastructure/config/fonts"; import { TERMINAL_THEMES } from "../../../infrastructure/config/terminalThemes"; import { customThemeStore, useCustomThemes } from "../../../application/state/customThemeStore"; import { parseItermcolors } from "../../../infrastructure/parsers/itermcolorsParser"; import { cn } from "../../../lib/utils"; import { useDiscoveredShells } from "../../../lib/useDiscoveredShells"; import { parseShellArgs, formatShellArgs } from "../../../domain/shellArgs"; import { Button } from "../../ui/button"; import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "../../ui/dialog"; import { Input } from "../../ui/input"; import { Select as ShadcnSelect, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select"; import { SectionHeader, Select, SettingsAnchor, SettingsTabContent, SettingRow, Toggle } from "../settings-ui"; import { ThemeSelectModal } from "../ThemeSelectModal"; import { TerminalFontSelect } from "../TerminalFontSelect"; import { TerminalCjkFontSelect } from "../TerminalCjkFontSelect"; import { CustomThemeModal } from "../../terminal/CustomThemeModal"; import type { TerminalTheme } from "../../../domain/models"; import { resolveFollowedTerminalThemeId, resolveManualTerminalThemeId } from "../../../domain/terminalAppearance"; import { KeywordHighlightRulesEditor, ThemePreviewButton } from "./SettingsTerminalTabControls"; import { TerminalBehaviorSettings } from "./TerminalBehaviorSettings"; import { TERMINAL_SIDE_PANEL_AUTO_OPEN_TABS, type TerminalSidePanelAutoOpenTab, } from "../../../domain/terminalSidePanelAutoOpen"; import { TERMINAL_INLINE_IMAGE_MAX_MEGAPIXELS_MAX, TERMINAL_INLINE_IMAGE_MAX_MEGAPIXELS_MIN, TERMINAL_INLINE_IMAGE_SEQUENCE_LIMIT_MB_MAX, TERMINAL_INLINE_IMAGE_SEQUENCE_LIMIT_MB_MIN, TERMINAL_INLINE_IMAGE_STORAGE_LIMIT_MB_MAX, TERMINAL_INLINE_IMAGE_STORAGE_LIMIT_MB_MIN, } from "../../../domain/terminalInlineImages"; const FONT_WEIGHT_OPTIONS = [ { value: "100", labelKey: "settings.terminal.font.weight.thin" }, { value: "200", labelKey: "settings.terminal.font.weight.extraLight" }, { value: "300", labelKey: "settings.terminal.font.weight.light" }, { value: "400", labelKey: "settings.terminal.font.weight.normal" }, { value: "500", labelKey: "settings.terminal.font.weight.medium" }, { value: "600", labelKey: "settings.terminal.font.weight.semiBold" }, { value: "700", labelKey: "settings.terminal.font.weight.bold" }, { value: "800", labelKey: "settings.terminal.font.weight.extraBold" }, { value: "900", labelKey: "settings.terminal.font.weight.black" }, ]; function SettingsTerminalTab(props: { terminalThemeId: string; setTerminalThemeId: (id: string) => void; resolvedTheme: "dark" | "light"; followAppTerminalTheme: boolean; setFollowAppTerminalTheme: (value: boolean) => void; terminalThemeDarkId: string; setTerminalThemeDarkId: (id: string) => void; terminalThemeLightId: string; setTerminalThemeLightId: (id: string) => void; lightUiThemeId: string; darkUiThemeId: string; terminalFontFamilyId: string; setTerminalFontFamilyId: (id: string) => void; terminalFontSize: number; setTerminalFontSize: (size: number) => void; terminalSettings: TerminalSettings; updateTerminalSetting: ( key: K, value: TerminalSettings[K], ) => void; terminalSidePanelAutoOpen: boolean; setTerminalSidePanelAutoOpen: (enabled: boolean) => void; terminalSidePanelAutoOpenTab: TerminalSidePanelAutoOpenTab; setTerminalSidePanelAutoOpenTab: (tab: TerminalSidePanelAutoOpenTab) => void; availableFonts: TerminalFont[]; workspaceFocusStyle: 'dim' | 'border'; setWorkspaceFocusStyle: (style: 'dim' | 'border') => void; }) { const { terminalThemeId, setTerminalThemeId, resolvedTheme, followAppTerminalTheme, setFollowAppTerminalTheme, terminalThemeDarkId, setTerminalThemeDarkId, terminalThemeLightId, setTerminalThemeLightId, lightUiThemeId, darkUiThemeId, terminalFontFamilyId, setTerminalFontFamilyId, terminalFontSize, setTerminalFontSize, terminalSettings, updateTerminalSetting, terminalSidePanelAutoOpen, setTerminalSidePanelAutoOpen, terminalSidePanelAutoOpenTab, setTerminalSidePanelAutoOpenTab, availableFonts, workspaceFocusStyle, setWorkspaceFocusStyle, } = props; const { t } = useI18n(); // Local shell settings state const [defaultShell, setDefaultShell] = useState(""); const [shellValidation, setShellValidation] = useState<{ valid: boolean; message?: string } | null>(null); const [dirValidation, setDirValidation] = useState<{ valid: boolean; message?: string } | null>(null); const discoveredShells = useDiscoveredShells(); const [showCustomShellInput, setShowCustomShellInput] = useState(() => { if (!terminalSettings.localShell) return false; return !discoveredShells.some(s => s.id === terminalSettings.localShell); }); const [customShellModalOpen, setCustomShellModalOpen] = useState(false); const [customShellDraft, setCustomShellDraft] = useState(""); const [customArgsDraft, setCustomArgsDraft] = useState(""); // Update showCustomShellInput once discovered shells load useEffect(() => { if (!terminalSettings.localShell) return; setShowCustomShellInput(!discoveredShells.some(s => s.id === terminalSettings.localShell)); }, [discoveredShells, terminalSettings.localShell]); // Seed the drafts from current settings and open the custom-shell editor. // Used both when picking "Custom…" and when re-editing an existing custom shell. const openCustomShellModal = useCallback(() => { setCustomShellDraft(terminalSettings.localShell || ""); setCustomArgsDraft(formatShellArgs(terminalSettings.localShellArgs ?? [])); setCustomShellModalOpen(true); }, [terminalSettings.localShell, terminalSettings.localShellArgs]); const [themeModalSlot, setThemeModalSlot] = useState<'dark' | 'light' | null>(null); // Subscribe to custom theme changes so editing in-place triggers re-render const customThemes = useCustomThemes(); const findTerminalTheme = useCallback((id: string) => ( TERMINAL_THEMES.find(t => t.id === id) || customThemes.find(t => t.id === id) || null ), [customThemes]); const followedPreviewTheme = useMemo(() => { const id = resolveFollowedTerminalThemeId({ resolvedTheme, lightUiThemeId, darkUiThemeId, fallbackThemeId: terminalThemeId, }); return findTerminalTheme(id) || findTerminalTheme(terminalThemeId) || TERMINAL_THEMES[0]; }, [darkUiThemeId, findTerminalTheme, lightUiThemeId, resolvedTheme, terminalThemeId]); const darkPreviewTheme = useMemo(() => { const id = resolveManualTerminalThemeId({ resolvedTheme: 'dark', terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, fallbackThemeId: terminalThemeId, }); return findTerminalTheme(id) || findTerminalTheme(terminalThemeId) || TERMINAL_THEMES[0]; }, [darkUiThemeId, findTerminalTheme, lightUiThemeId, terminalThemeDarkId, terminalThemeId, terminalThemeLightId]); const lightPreviewTheme = useMemo(() => { const id = resolveManualTerminalThemeId({ resolvedTheme: 'light', terminalThemeDarkId, terminalThemeLightId, lightUiThemeId, darkUiThemeId, fallbackThemeId: terminalThemeId, }); return findTerminalTheme(id) || findTerminalTheme(terminalThemeId) || TERMINAL_THEMES[0]; }, [darkUiThemeId, findTerminalTheme, lightUiThemeId, terminalThemeDarkId, terminalThemeId, terminalThemeLightId]); const currentTheme = followAppTerminalTheme ? followedPreviewTheme : resolvedTheme === 'dark' ? darkPreviewTheme : lightPreviewTheme; const setManualThemeForResolvedMode = useCallback((themeId: string) => { if (resolvedTheme === 'dark') { setTerminalThemeDarkId(themeId); } else { setTerminalThemeLightId(themeId); } setTerminalThemeId(themeId); }, [resolvedTheme, setTerminalThemeDarkId, setTerminalThemeId, setTerminalThemeLightId]); const fontWeightOptions = useMemo(() => ( FONT_WEIGHT_OPTIONS.map((option) => ({ value: option.value, label: `${option.value} - ${t(option.labelKey)}`, })) ), [t]); const handleAutocompleteGhostTextChange = useCallback((enabled: boolean) => { updateTerminalSetting("autocompleteGhostText", enabled); if (enabled) { updateTerminalSetting("autocompletePopupMenu", false); } }, [updateTerminalSetting]); const handleAutocompletePopupMenuChange = useCallback((enabled: boolean) => { updateTerminalSetting("autocompletePopupMenu", enabled); if (enabled) { updateTerminalSetting("autocompleteGhostText", false); } }, [updateTerminalSetting]); // Import .itermcolors file const importFileRef = useRef(null); const handleImportItermcolors = useCallback((e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) { return; } const name = file.name.replace(/\.(itermcolors|xml)$/i, ''); const reader = new FileReader(); reader.onload = () => { const xml = reader.result as string; const parsed = parseItermcolors(xml, name); if (parsed) { customThemeStore.addTheme(parsed); setManualThemeForResolvedMode(parsed.id); } else { console.error('[Settings] Failed to parse .itermcolors file:', file.name); window.alert(t('terminal.customTheme.importError') || 'Failed to parse the selected file. Please ensure it is a valid .itermcolors XML file.'); } }; reader.onerror = () => { console.error('[Settings] Failed to read file:', file.name, reader.error); }; reader.readAsText(file); e.target.value = ''; }, [setManualThemeForResolvedMode, t]); // New custom theme modal const [customThemeModalOpen, setCustomThemeModalOpen] = useState(false); const [customThemeData, setCustomThemeData] = useState(null); const [isEditingTheme, setIsEditingTheme] = useState(false); // Check if current theme is a custom theme const isCustomTheme = useMemo(() => { return currentTheme?.isCustom === true; }, [currentTheme]); const handleNewCustomTheme = useCallback(() => { const base = currentTheme || TERMINAL_THEMES[0]; const newTheme: TerminalTheme = { ...base, id: `custom-${Date.now()}`, name: `${base.name} (Custom)`, isCustom: true, colors: { ...base.colors }, }; setCustomThemeData(newTheme); setIsEditingTheme(false); setCustomThemeModalOpen(true); }, [currentTheme]); const handleEditCustomTheme = useCallback(() => { if (!currentTheme?.isCustom) return; setCustomThemeData({ ...currentTheme, colors: { ...currentTheme.colors } }); setIsEditingTheme(true); setCustomThemeModalOpen(true); }, [currentTheme]); const handleDeleteCustomTheme = useCallback(() => { if (!currentTheme?.isCustom) return; customThemeStore.deleteTheme(currentTheme.id); setManualThemeForResolvedMode(followedPreviewTheme.id); }, [currentTheme, followedPreviewTheme.id, setManualThemeForResolvedMode]); // Fetch default shell on mount useEffect(() => { const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty; if (bridge?.getDefaultShell) { bridge.getDefaultShell().then((shell) => { setDefaultShell(shell); }).catch(() => { // Ignore errors - might not be in Electron }); } }, []); // Validate shell path when it changes (only for custom paths, not discovered shell ids) useEffect(() => { const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty; const shellPath = terminalSettings.localShell; if (!shellPath) { setShellValidation(null); return; } // Skip validation for discovered shell ids — only validate custom paths if (discoveredShells.some(s => s.id === shellPath)) { setShellValidation(null); return; } if (!bridge?.validatePath) { setShellValidation(null); return; } const timeoutId = setTimeout(() => { bridge.validatePath(shellPath, 'file').then((result) => { if (result.exists && result.isFile) { setShellValidation({ valid: true }); } else if (result.exists && result.isDirectory) { setShellValidation({ valid: false, message: t("settings.terminal.localShell.shell.isDirectory") }); } else { setShellValidation({ valid: false, message: t("settings.terminal.localShell.shell.notFound") }); } }).catch(() => { setShellValidation(null); }); }, 300); return () => clearTimeout(timeoutId); }, [terminalSettings.localShell, discoveredShells, t]); // Validate directory path when it changes useEffect(() => { const bridge = (window as unknown as { netcatty?: NetcattyBridge }).netcatty; const dirPath = terminalSettings.localStartDir; if (!dirPath) { setDirValidation(null); return; } if (!bridge?.validatePath) { setDirValidation(null); return; } const timeoutId = setTimeout(() => { bridge.validatePath(dirPath, 'directory').then((result) => { if (result.exists && result.isDirectory) { setDirValidation({ valid: true }); } else if (result.exists && result.isFile) { setDirValidation({ valid: false, message: t("settings.terminal.localShell.startDir.isFile") }); } else { setDirValidation({ valid: false, message: t("settings.terminal.localShell.startDir.notFound") }); } }).catch(() => { setDirValidation(null); }); }, 300); return () => clearTimeout(timeoutId); }, [terminalSettings.localStartDir, t]); const clampFontSize = useCallback((next: number) => { const safe = Math.max(MIN_FONT_SIZE, Math.min(MAX_FONT_SIZE, next)); setTerminalFontSize(safe); }, [setTerminalFontSize]); return (
{!followAppTerminalTheme && (
{t("settings.terminal.theme.darkTheme")}
setThemeModalSlot('dark')} buttonLabel={t("settings.terminal.theme.selectButton")} />
{t("settings.terminal.theme.lightTheme")}
setThemeModalSlot('light')} buttonLabel={t("settings.terminal.theme.selectButton")} />
)} setThemeModalSlot(null)} selectedThemeId={themeModalSlot === 'dark' ? darkPreviewTheme.id : lightPreviewTheme.id} onSelect={(id) => { if (themeModalSlot === 'dark') { setTerminalThemeDarkId(id); } else if (themeModalSlot === 'light') { setTerminalThemeLightId(id); } if (themeModalSlot === resolvedTheme) { setTerminalThemeId(id); } }} filterType={themeModalSlot === 'light' ? 'light' : 'dark'} /> {!followAppTerminalTheme && (
{isCustomTheme && ( <> )}
)} {/* Custom Theme Modal */} {customThemeData && ( { if (isEditingTheme) { customThemeStore.updateTheme(theme.id, theme); } else { customThemeStore.addTheme(theme); } setManualThemeForResolvedMode(theme.id); setCustomThemeModalOpen(false); setCustomThemeData(null); }} onDelete={isEditingTheme ? (themeId) => { customThemeStore.deleteTheme(themeId); setManualThemeForResolvedMode(followedPreviewTheme.id); setCustomThemeModalOpen(false); setCustomThemeData(null); } : undefined} onCancel={() => { setCustomThemeModalOpen(false); setCustomThemeData(null); }} /> )}
setTerminalFontFamilyId(id)} className="w-48" ariaLabel={t("settings.terminal.font.family")} /> updateTerminalSetting("fallbackFont", next)} />
{terminalFontSize}px
updateTerminalSetting("fontWeightBold", parseInt(v))} className="w-40" /> updateTerminalSetting("fontSmoothing", v)} />
updateTerminalSetting("linePadding", parseInt(e.target.value))} className="w-24 accent-primary" /> {terminalSettings.linePadding}
updateTerminalSetting("cursorShape", v as CursorShape)} className="w-32" /> updateTerminalSetting("cursorBlink", v)} /> updateTerminalSetting("highlightCursorLine", v)} />
updateTerminalSetting("altAsMeta", v)} /> updateTerminalSetting("optionArrowWordJump", v)} /> updateTerminalSetting("kittyKeyboardProtocolEnabled", v)} />
updateTerminalSetting("minimumContrastRatio", parseInt(e.target.value)) } className="w-24 accent-primary" /> {terminalSettings.minimumContrastRatio}
updateTerminalSetting("localStartDir", e.target.value)} className={cn( "w-48", dirValidation && !dirValidation.valid && "border-destructive focus-visible:ring-destructive" )} /> {dirValidation && !dirValidation.valid && dirValidation.message && ( {dirValidation.message} )}
updateTerminalSetting("verifyHostKeys", v)} /> updateTerminalSetting("sshAutoReconnectEnabled", v)} /> { const val = parseInt(e.target.value) || 0; if (val >= 0 && val <= 3600) { updateTerminalSetting("keepaliveInterval", val); } }} className="w-24" /> { const val = parseInt(e.target.value) || 1; if (val >= 1 && val <= 100) { updateTerminalSetting("keepaliveCountMax", val); } }} className="w-24" /> updateTerminalSetting("x11Display", e.target.value)} placeholder={t("settings.terminal.connection.x11Display.placeholder")} className="w-48" />
updateTerminalSetting("showHostInfoBar", v)} /> {terminalSettings.showHostInfoBar && ( { const val = parseInt(e.target.value) || 5; if (val >= 5 && val <= 300) { updateTerminalSetting("serverStatsRefreshInterval", val); } }} className="w-20" /> {t("settings.terminal.serverStats.seconds")}
)}
{ const val = parseInt(e.target.value, 10) || 3; if (val >= 2 && val <= 60) { updateTerminalSetting("systemManagerProcessRefreshInterval", val); } }} className="w-20" /> {t("settings.terminal.serverStats.seconds")}
{ const val = parseInt(e.target.value, 10) || 3; if (val >= 2 && val <= 60) { updateTerminalSetting("systemManagerTmuxRefreshInterval", val); } }} className="w-20" /> {t("settings.terminal.serverStats.seconds")}
{ const val = parseInt(e.target.value, 10) || 5; if (val >= 3 && val <= 120) { updateTerminalSetting("systemManagerDockerListRefreshInterval", val); } }} className="w-20" /> {t("settings.terminal.serverStats.seconds")}
{ const val = parseInt(e.target.value, 10) || 3; if (val >= 2 && val <= 60) { updateTerminalSetting("systemManagerDockerStatsRefreshInterval", val); } }} className="w-20" /> {t("settings.terminal.serverStats.seconds")}
{ const val = parseInt(e.target.value, 10); if (!Number.isNaN(val) && val >= 5 && val <= 600) { updateTerminalSetting("hibernateHiddenTabsDelaySec", val); } }} className="w-20" /> {t("settings.terminal.serverStats.seconds")}
updateTerminalSetting("hibernateSkipAltScreen", v)} /> { const val = parseInt(e.target.value, 10); if (!Number.isNaN(val) && val >= 0 && val <= 12) { updateTerminalSetting("hibernateKeepRendererCount", val); } }} className="w-20" /> { const val = parseInt(e.target.value, 10); if (!Number.isNaN(val) && val >= 4096 && val <= 65536) { updateTerminalSetting("hibernateReplayChunkBytes", val); } }} className="w-28" /> updateTerminalSetting("hibernatePreferWasmSerialize", v)} /> )}
updateTerminalSetting("inlineImagesEnabled", v)} /> {terminalSettings.inlineImagesEnabled && ( <> updateTerminalSetting("inlineImageKittyEnabled", v)} /> updateTerminalSetting("inlineImageSixelEnabled", v)} /> updateTerminalSetting("inlineImageIipEnabled", v)} />
{ const val = parseInt(e.target.value, 10); if ( !Number.isNaN(val) && val >= TERMINAL_INLINE_IMAGE_STORAGE_LIMIT_MB_MIN && val <= TERMINAL_INLINE_IMAGE_STORAGE_LIMIT_MB_MAX ) { updateTerminalSetting("inlineImageStorageLimitMb", val); } }} className="w-20" /> {t("settings.terminal.inlineImages.unit.mb")}
{ const val = parseInt(e.target.value, 10); if ( !Number.isNaN(val) && val >= TERMINAL_INLINE_IMAGE_MAX_MEGAPIXELS_MIN && val <= TERMINAL_INLINE_IMAGE_MAX_MEGAPIXELS_MAX ) { updateTerminalSetting("inlineImageMaxMegapixels", val); } }} className="w-20" /> {t("settings.terminal.inlineImages.unit.megapixels")}
{ const val = parseInt(e.target.value, 10); if ( !Number.isNaN(val) && val >= TERMINAL_INLINE_IMAGE_SEQUENCE_LIMIT_MB_MIN && val <= TERMINAL_INLINE_IMAGE_SEQUENCE_LIMIT_MB_MAX ) { updateTerminalSetting("inlineImageSequenceLimitMb", val); } }} className="w-20" /> {t("settings.terminal.inlineImages.unit.mb")}
{terminalSettings.hibernateHiddenTabs && (
{t("settings.terminal.inlineImages.hibernateNote")}
)} )}
{/* Autocomplete */}
updateTerminalSetting( "autocompleteHistoryScope", v as AutocompleteHistoryScope, ) } options={[ { value: "host", label: t("settings.terminal.autocomplete.historyScope.host"), }, { value: "global", label: t("settings.terminal.autocomplete.historyScope.global"), }, ]} className="w-48" disabled={!terminalSettings.autocompleteEnabled} />
setCustomShellDraft(e.target.value)} className="w-full" autoFocus /> {shellValidation && !shellValidation.valid && shellValidation.message && ( {shellValidation.message} )} {shellValidation?.valid && ( ✓ {t("settings.terminal.localShell.shell.pathValid")} )}
setCustomArgsDraft(e.target.value)} className="w-full" /> {t("settings.terminal.localShell.shell.customArgs.desc")}
{["/bin/bash", "/bin/zsh", "/usr/bin/fish", "/bin/sh", "powershell.exe", "pwsh.exe", "cmd.exe"].map((p) => ( ))}
); } export default React.memo(SettingsTerminalTab);