Files

687 lines
26 KiB
Swift

// SPDX-FileCopyrightText: 2026 Kaede Akino
// SPDX-License-Identifier: GPL-3.0-only
import AgentKit
import GhosttyTheme
import SwiftUI
import TermindApplication
import TermindDomain
import TermindPresentation
import struct TermindDomain.Host
nonisolated enum SettingsCategory: String, CaseIterable, Identifiable, Sendable {
case app, notifications, terminal, connections, ai, sync
#if DEBUG
case developer
#endif
var id: String { rawValue }
var title: LocalizedStringResource {
switch self {
case .notifications: AppStrings.Notifications.title
case .app: AppStrings.Settings.app
case .terminal: AppStrings.Settings.terminal
case .connections: AppStrings.Settings.connections
case .ai: AppStrings.Settings.ai
case .sync: AppStrings.Settings.sync
#if DEBUG
case .developer: AppStrings.Settings.developer
#endif
}
}
func title(locale: Locale = .current) -> String {
switch self {
case .notifications: AppLocalization.resolve(AppStrings.Notifications.title, locale: locale)
case .app: AppLocalization.resolve(AppStrings.Settings.app, locale: locale)
case .terminal: AppLocalization.resolve(AppStrings.Settings.terminal, locale: locale)
case .connections: AppLocalization.resolve(AppStrings.Settings.connections, locale: locale)
case .ai: AppLocalization.resolve(AppStrings.Settings.ai, locale: locale)
case .sync: AppLocalization.resolve(AppStrings.Settings.sync, locale: locale)
#if DEBUG
case .developer: AppLocalization.resolve("Developer", locale: locale)
#endif
}
}
var symbol: String {
switch self {
case .notifications: "bell.badge"
case .app: "app.badge"
case .terminal: "terminal"
case .connections: MenuSymbol.connect
case .ai: "brain"
case .sync: "icloud"
#if DEBUG
case .developer: "hammer"
#endif
}
}
}
enum SettingsRoute: Hashable {
case index
case category(SettingsCategory)
case modelProvider(ModelProvider.ID)
case providerModels(ModelProvider.ID)
case mcpServer(MCPServer.ID, isNew: Bool)
case localMCPServer
case skill(AgentSkill.ID, isNew: Bool)
case terminalThemes(TerminalCustomConfigurationScheme)
case terminalCustomThemes
case terminalCustomTheme(UUID)
case logging
var parent: SettingsRoute? {
switch self {
case .index:
nil
case .category:
.index
case .modelProvider:
.category(.ai)
case .providerModels(let id):
.modelProvider(id)
case .mcpServer:
.category(.ai)
case .localMCPServer:
.category(.ai)
case .skill:
.category(.ai)
case .terminalThemes:
.category(.terminal)
case .terminalCustomThemes:
.category(.terminal)
case .terminalCustomTheme:
.terminalCustomThemes
case .logging:
.index
}
}
}
struct SettingsPageView: View {
@Environment(AppModel.self) private var app
@Environment(WindowModel.self) private var window
@State private var newlyCreatedProviderID: ModelProvider.ID?
var body: some View {
detail.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(TermindColor.windowSurface)
.onChange(of: window.settingsRoute) { oldRoute, newRoute in
cleanUpDraft(from: oldRoute, movingTo: newRoute)
if case .modelProvider = oldRoute { newlyCreatedProviderID = nil }
}
.onDisappear {
cleanUpDraft(from: window.settingsRoute, movingTo: .index)
}
.confirmationDialog(
window.terminalThemeDraft?.isNew == true
? AppStrings.Settings.discardNewCustomThemeQuestion : AppStrings.Common.discardChangesQuestion,
isPresented: Binding(
get: { window.showsDiscardTerminalTheme },
set: { if !$0 { window.showsDiscardTerminalTheme = false } }),
titleVisibility: .visible
) {
Button(AppStrings.Common.discardChanges.swiftUIKey, role: .destructive) {
window.discardTerminalThemeAndNavigate()
}
Button(AppStrings.Common.cancel.swiftUIKey, role: .cancel) {
window.cancelTerminalThemeNavigation()
}
}
}
@ViewBuilder
private var detail: some View {
switch window.settingsRoute {
case .index:
SettingsIndexPage(
categories: SettingsCategory.allCases,
select: { navigate(to: .category($0)) },
openLogging: { navigate(to: .logging) },
openPro: { app.proAccess.presentPaywall() }
)
case .category(let category):
categoryPage(category)
case .modelProvider(let id):
ModelProviderEditorPage(
modelProviderID: id,
isNew: newlyCreatedProviderID == id,
showModels: { navigate(to: .providerModels(id)) },
deleted: { navigate(to: .category(.ai)) }
)
case .providerModels(let id):
ModelListEditor(modelProviderID: id)
case .mcpServer(let id, _):
MCPServerEditorPage(
serverID: id,
deleted: { navigate(to: .category(.ai)) }
)
case .localMCPServer:
SettingsFormPage { MCPServerSettings() }
case .skill(let id, _):
SkillEditorPage(skillID: id, deleted: { navigate(to: .category(.ai)) })
case .terminalThemes(let scheme):
TerminalThemePage(scheme: scheme)
case .terminalCustomThemes:
TerminalCustomThemesPage()
case .terminalCustomTheme:
if let draft = window.terminalThemeDraft {
TerminalCustomConfigurationEditorPage(
draft: draft, model: app.terminalSettings,
availableFontFamilies: TerminalFontCatalog.shared.families
)
}
case .logging:
SettingsFormPage { LoggingFilesSettings() }
}
}
@ViewBuilder
private func categoryPage(_ category: SettingsCategory) -> some View {
switch category {
case .app:
SettingsFormPage {
AppSettings()
StartupSettings()
DiagnosisSettingsSection(controller: app.diagnosis)
AppInfoSettings()
}
case .notifications:
SettingsFormPage {
NotificationSettingsFields(
model: app.notificationSettings, service: app.notifications,
openSettings: TerminalNotificationPlatform.openSettings)
}
case .terminal:
SettingsFormPage {
TerminalSettings(
chooseTheme: { navigate(to: .terminalThemes($0)) },
manageCustomThemes: { navigate(to: .terminalCustomThemes) }
)
}
case .connections:
SettingsFormPage { ConnectionSettings() }
case .ai:
SettingsFormPage {
ModelProviderSettings { id, isNew in
newlyCreatedProviderID = isNew ? id : nil
navigate(to: .modelProvider(id))
}
ModelRoleSettings()
WebSearchSettings()
SkillSettings(
edit: { id, isNew in navigate(to: .skill(id, isNew: isNew)) }
)
MCPSettings(
edit: { id, isNew in navigate(to: .mcpServer(id, isNew: isNew)) },
openServer: { navigate(to: .localMCPServer) }
)
MemorySettingsSection(memory: app.memory, ai: app.aiConfiguration)
}
case .sync:
SettingsFormPage { SyncSettings() }
#if DEBUG
case .developer:
DeveloperSettingsPage()
#endif
}
}
private func navigate(to route: SettingsRoute) {
window.settingsRoute = route
}
private func cleanUpDraft(from route: SettingsRoute, movingTo newRoute: SettingsRoute) {
switch route {
case .modelProvider(let id):
if case .providerModels(id) = newRoute { return }
guard let provider = app.aiConfiguration.modelProvider(id) else { return }
if SettingsDraftPolicy.shouldDiscard(provider) { app.aiConfiguration.delete(modelProviderID: id) }
case .providerModels(let id):
if case .modelProvider(id) = newRoute { return }
guard let provider = app.aiConfiguration.modelProvider(id) else { return }
if SettingsDraftPolicy.shouldDiscard(provider) { app.aiConfiguration.delete(modelProviderID: id) }
case .mcpServer(let id, isNew: true):
guard let server = app.aiConfiguration.mcpServer(id) else { return }
if SettingsDraftPolicy.shouldDiscard(server)
|| !app.aiConfiguration.lockMCPNamespace(for: id)
{
app.aiConfiguration.delete(serverID: id)
}
case .skill(let id, isNew: true):
guard let skill = app.skills.skill(id) else { return }
// Unlike an MCP server, a skill that cannot be named yet is kept.
// It is inert either way — an unnamed skill is never listed to a
// model — and someone who wrote the procedure before the title has
// done the expensive part of the work. It shows as a draft in the
// list until they finish it.
if SettingsDraftPolicy.shouldDiscard(skill) {
app.skills.delete(id: id)
} else {
app.skills.lockName(for: id)
}
default:
break
}
}
}
private struct SettingsIndexPage: View {
@AppStorage(SettingsKey.debugSectionEnabled) private var debugSectionEnabled = false
let categories: [SettingsCategory]
let select: (SettingsCategory) -> Void
let openLogging: () -> Void
let openPro: () -> Void
var body: some View {
Form {
Section {
Button(action: openPro) {
Label(AppStrings.Pro.termindPro.swiftUIKey, systemImage: "sparkles.rectangle.stack")
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityIdentifier("settings.openPro")
}
Section {
ForEach(regularCategories) { category in
categoryButton(category)
}
}
if debugSectionEnabled {
Section {
Button(action: openLogging) {
HStack {
Label(AppStrings.Settings.logging.swiftUIKey, systemImage: "doc.text")
Spacer()
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
}
}
.buttonStyle(.plain)
LabeledContent {
Text(verbatim: String(DeviceCensorship.canDisplayTaiwanFlag))
} label: {
Label(AppStrings.Settings.taiwanFlagAvailable.swiftUIKey, systemImage: "touchid")
}
}
}
#if DEBUG
if categories.contains(.developer) {
Section { categoryButton(.developer) }
}
#endif
}
.formStyle(.grouped)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
private var regularCategories: [SettingsCategory] {
categories.filter { category in
switch category {
#if DEBUG
case .developer:
false
#endif
default:
true
}
}
}
private func categoryButton(_ category: SettingsCategory) -> some View {
Button {
select(category)
} label: {
SettingsCategoryRow(category: category)
}
.buttonStyle(.plain)
}
}
private struct SettingsCategoryRow: View {
let category: SettingsCategory
var body: some View {
HStack(spacing: Metrics.Space.m) {
Label(category.title, systemImage: category.symbol)
Spacer(minLength: Metrics.Space.m)
Image(systemName: "chevron.right")
.font(.caption.weight(.semibold))
.foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
}
}
enum SettingsDraftPolicy {
static func shouldDiscard(_ provider: ModelProvider) -> Bool {
let hasEmptyName = provider.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
let hasConfiguration =
!provider.inferenceURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| provider.hasCredential || !provider.models.isEmpty
return hasEmptyName && !hasConfiguration
}
static func shouldDiscard(_ server: MCPServer) -> Bool {
server.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| server.effectiveNamespaceID.isEmpty
}
/// A skill nobody typed anything into. Judged on all three fields rather
/// than on the name alone: someone who wrote the instructions first and
/// walked away has a draft worth keeping, not an accident to clean up.
static func shouldDiscard(_ skill: AgentSkill) -> Bool {
skill.effectiveName.isEmpty
&& skill.summary.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
&& skill.body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
}
struct SettingsFormPage<Content: View>: View {
@ViewBuilder let content: Content
var body: some View {
Form { content }
.formStyle(.grouped)
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
private struct AppSettings: View {
@Environment(AppModel.self) private var app
@AppStorage(DesktopSettingsKey.showStatusBar) private var showsStatusBar = true
@AppStorage(DesktopSettingsKey.showMetricsInMenuBar) private var showMetricsInMenuBar = true
@AppStorage(DesktopSettingsKey.appAppearance) private var appearanceID = AppAppearance.system.rawValue
@AppStorage(SettingsKey.appLanguage) private var languageID = AppLanguage.system.rawValue
@State private var launchLanguage = AppLanguage.stored()
private var selectedLanguage: AppLanguage {
AppLanguage(rawValue: languageID) ?? AppLanguage.stored()
}
private var languageSelection: Binding<AppLanguage> {
Binding(
get: { selectedLanguage },
set: { language in
languageID = language.rawValue
AppLanguage.store(language)
})
}
var body: some View {
Section(AppStrings.Settings.appearance.swiftUIKey) {
Toggle(AppStrings.Settings.showInMenuBar.swiftUIKey, isOn: $showMetricsInMenuBar)
Toggle(
AppStrings.Settings.showStatusBar.swiftUIKey,
isOn: $showsStatusBar
)
Picker(
AppStrings.Settings.theme.swiftUIKey,
selection: Binding(
get: { AppAppearance(rawValue: appearanceID) ?? .system },
set: { appearanceID = $0.rawValue }
)
) {
ForEach(AppAppearance.allCases) { appearance in
Text(appearance.displayName).tag(appearance)
}
}
Picker(AppStrings.Settings.language.swiftUIKey, selection: languageSelection) {
ForEach(AppLanguage.allCases) { language in
Text(
language.displayName(
deviceRegion: Locale.autoupdatingCurrent.region?.identifier,
canDisplayTaiwanFlag: DeviceCensorship.canDisplayTaiwanFlag
)
).tag(language)
}
}
if selectedLanguage != launchLanguage {
Text(
verbatim: AppLocalization.resolve(
AppStrings.Settings.theNewLanguageWillBeUsedAfterTermindRelaunches,
locale: selectedLanguage.locale)
)
.foregroundStyle(.secondary)
}
}
Section {
Toggle(
AppStrings.Settings.automaticallyRenameWorkspaces.swiftUIKey,
isOn: Binding(
get: { app.workspaceSettings.preferences.automaticallyRenamesWorkspaces },
set: { _ = app.workspaceSettings.setAutomaticRenaming($0) }
)
)
if let error = app.workspaceSettings.storageError {
Text(error).foregroundStyle(.orange)
}
} header: {
Text(AppStrings.Navigation.workspaces)
} footer: {
Text(AppStrings.Settings.workspaceNumberingExplanation)
}
}
}
private struct AppInfoSettings: View {
@Environment(AppModel.self) private var app
@AppStorage(SettingsKey.debugSectionEnabled) private var debugSectionEnabled = false
var body: some View {
Section(AppStrings.Settings.version.swiftUIKey) {
LabeledContent {
Text(Bundle.termindDisplayVersion)
.foregroundStyle(.secondary)
.textSelection(.enabled)
} label: {
Text(AppStrings.Settings.app)
}
.contentShape(.rect)
.simultaneousGesture(
TapGesture(count: 3)
.onEnded { debugSectionEnabled.toggle() }
)
LabeledContent(AppStrings.Settings.ghostty.swiftUIKey) {
Text(RuntimeVersions.ghostty).foregroundStyle(.secondary).textSelection(.enabled)
}
LabeledContent(AppStrings.Settings.libssh2.swiftUIKey) {
Text(RuntimeVersions.libssh2).foregroundStyle(.secondary).textSelection(.enabled)
}
LabeledContent(AppStrings.Settings.openSSL.swiftUIKey) {
Text(RuntimeVersions.openSSL).foregroundStyle(.secondary).textSelection(.enabled)
}
LabeledContent(AppStrings.Tailscale.title.swiftUIKey) {
Text(RuntimeVersions.tailscale).foregroundStyle(.secondary).textSelection(.enabled)
}
}
Section(AppStrings.Settings.support.swiftUIKey) {
AppReviewLink(controller: app.appReview) {
Label(AppStrings.Settings.rateOnTheAppStore.swiftUIKey, systemImage: "star")
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(.rect)
}
.buttonStyle(.plain)
.foregroundStyle(.tint)
Link(destination: URL(string: "https://termind.akinokaede.com/support/")!) {
Label(AppStrings.Settings.support.swiftUIKey, systemImage: "questionmark.circle")
.frame(maxWidth: .infinity, alignment: .leading)
}
Link(destination: URL(string: "https://termind.akinokaede.com/privacy/")!) {
Label(AppStrings.Pro.privacyPolicy.swiftUIKey, systemImage: "hand.raised")
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
}
private struct StartupSettings: View {
@Environment(\.appearsActive) private var appearsActive
@State private var loginItem = LoginItemController()
@AppStorage(DesktopSettingsKey.openAtLoginQuietly) private var opensQuietly =
DesktopSettingsKey.defaultOpenAtLoginQuietly
var body: some View {
Section(AppStrings.Settings.startup.swiftUIKey) {
Toggle(
AppStrings.Settings.openAtLogin.swiftUIKey,
isOn: Binding(
get: { loginItem.isEnabled },
set: { loginItem.setEnabled($0) }
)
)
Toggle(AppStrings.Settings.startMinimizedToSystemTray.swiftUIKey, isOn: $opensQuietly)
.disabled(!loginItem.isEnabled)
if loginItem.requiresApproval {
LabeledContent {
Button(AppStrings.Settings.openLoginItemsSettings.swiftUIKey) { loginItem.openSystemSettings() }
} label: {
Text(AppStrings.Settings.macOSIsWaitingForYouToAllowTermindInLoginItems)
.foregroundStyle(.secondary)
}
}
if let message = loginItem.errorMessage {
Text(message).font(.caption).foregroundStyle(.orange)
}
}
// The registration lives in System Settings, where it can be revoked
// without telling us. Re-read it whenever this window is looked at.
.onAppear { loginItem.refresh() }
.onChange(of: appearsActive) { _, isActive in
if isActive { loginItem.refresh() }
}
}
}
private struct ConnectionSettings: View {
@Environment(AppModel.self) private var app
var body: some View {
Section(AppStrings.Vault.ssh.swiftUIKey) { SSHSettingsFields(model: app.sshSettings) }
Section(AppStrings.SFTP.sftp.swiftUIKey) {
SFTPSettingsFields(
model: app.sftpSettings,
access: .init(isAllowed: app.proAccess.canUse(.sftpMultiConnection)) {
app.proAccess.require(.sftpMultiConnection)
})
}
}
}
private struct TerminalSettings: View {
let chooseTheme: (TerminalCustomConfigurationScheme) -> Void
let manageCustomThemes: () -> Void
@Environment(AppModel.self) private var app
@TerminalFontSettings private var font
@Environment(\.scenePhase) private var scenePhase
private func binding<Value>(_ keyPath: WritableKeyPath<TerminalPreferences, Value>) -> Binding<Value> {
Binding(
get: { app.terminalSettings.preferences[keyPath: keyPath] },
set: { app.terminalSettings.set($0, for: keyPath) })
}
private func themeRow(_ scheme: TerminalCustomConfigurationScheme) -> some View {
let options = TerminalThemeOptions(
preferences: app.terminalSettings.preferences, isDark: scheme == .dark,
library: app.terminalSettings.themeLibrary)
return Button {
chooseTheme(scheme)
} label: {
HStack {
Text(options.title)
Spacer()
options.summary.foregroundStyle(.secondary)
Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
.contentShape(.rect)
}
.buttonStyle(.plain)
.accessibilityElement(children: .ignore)
.accessibilityLabel(Text(options.title))
.accessibilityValue(options.summary)
}
var body: some View {
Group {
if let error = app.terminalSettings.storageError {
Text(error).foregroundStyle(.orange)
}
Section(AppStrings.Settings.theme.swiftUIKey) {
themeRow(.light)
themeRow(.dark)
Button(action: manageCustomThemes) {
HStack {
Text(AppStrings.Settings.customThemes)
Spacer()
Image(systemName: "chevron.right").font(.caption).foregroundStyle(.tertiary)
.accessibilityHidden(true)
}
}
.buttonStyle(.plain)
}
Section(AppStrings.Settings.font.swiftUIKey) {
TerminalFontFields(preferences: $font, families: TerminalFontCatalog.shared.families)
}
Section(AppStrings.Settings.mouse.swiftUIKey) {
Toggle(AppStrings.Settings.copyOnSelection.swiftUIKey, isOn: binding(\.copyOnSelect))
Toggle(isOn: binding(\.pasteOnRightClick)) {
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
Text(AppStrings.Settings.pasteOnRightClick)
Text(
AppStrings.Settings.rightClickPasteExplanation
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
Section(AppStrings.Settings.bell.swiftUIKey) {
Toggle(
AppStrings.Settings.sound.swiftUIKey,
isOn: Binding(
get: { app.terminalExperienceSettings.preferences.bellSound },
set: { app.terminalExperienceSettings.set($0, for: \.bellSound) }))
}
Section(AppStrings.Settings.integration.swiftUIKey) {
Toggle(isOn: binding(\.shellIntegration)) {
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
Text(AppStrings.Settings.shellIntegration)
Text(
AppStrings.Settings.shellIntegrationExplanation
)
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
}
.onAppear {
TerminalFontCatalog.shared.refresh()
}
.onChange(of: scenePhase) { _, phase in
if phase == .active { TerminalFontCatalog.shared.refresh() }
}
}
}