756 lines
28 KiB
Swift
756 lines
28 KiB
Swift
import AgentKit
|
|
import GhosttyTheme
|
|
import SwiftUI
|
|
import TermindApplication
|
|
import TermindDomain
|
|
import TermindPresentation
|
|
|
|
import struct TermindDomain.Host
|
|
|
|
nonisolated enum SettingsCategory: String, CaseIterable, Identifiable, Sendable {
|
|
case pro, app, terminal, connections, ai, sync
|
|
#if DEBUG
|
|
case developer
|
|
#endif
|
|
|
|
var id: String { rawValue }
|
|
|
|
var title: LocalizedStringKey {
|
|
switch self {
|
|
case .pro: "Termind Pro"
|
|
case .app: "App"
|
|
case .terminal: "Terminal"
|
|
case .connections: "Connections"
|
|
case .ai: "AI"
|
|
case .sync: "Sync"
|
|
#if DEBUG
|
|
case .developer: "Developer"
|
|
#endif
|
|
}
|
|
}
|
|
|
|
func title(locale: Locale = .current) -> String {
|
|
switch self {
|
|
case .pro: AppLocalization.string("Termind Pro", locale: locale)
|
|
case .app: AppLocalization.string("App", locale: locale)
|
|
case .terminal: AppLocalization.string("Terminal", locale: locale)
|
|
case .connections: AppLocalization.string("Connections", locale: locale)
|
|
case .ai: AppLocalization.string("AI", locale: locale)
|
|
case .sync: AppLocalization.string("Sync", locale: locale)
|
|
#if DEBUG
|
|
case .developer: AppLocalization.string("Developer", locale: locale)
|
|
#endif
|
|
}
|
|
}
|
|
|
|
var symbol: String {
|
|
switch self {
|
|
case .pro: "sparkles.rectangle.stack"
|
|
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 terminalConfiguration(TerminalCustomConfigurationScheme)
|
|
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 .terminalConfiguration:
|
|
.category(.terminal)
|
|
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)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var detail: some View {
|
|
switch window.settingsRoute {
|
|
case .index:
|
|
SettingsIndexPage(
|
|
categories: SettingsCategory.allCases,
|
|
select: { navigate(to: .category($0)) },
|
|
openLogging: { navigate(to: .logging) }
|
|
)
|
|
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, _):
|
|
if app.proAccess.canUse(.remoteMCP) {
|
|
MCPServerEditorPage(
|
|
serverID: id,
|
|
deleted: { navigate(to: .category(.ai)) }
|
|
)
|
|
} else {
|
|
ProLockedSettingsPage(title: "MCP", feature: .remoteMCP)
|
|
}
|
|
case .localMCPServer:
|
|
SettingsFormPage { MCPServerSettings() }
|
|
case .skill(let id, _):
|
|
SkillEditorPage(skillID: id, deleted: { navigate(to: .category(.ai)) })
|
|
case .terminalConfiguration(let scheme):
|
|
TerminalCustomConfigurationEditorPage(scheme: scheme)
|
|
case .logging:
|
|
SettingsFormPage { LoggingFilesSettings() }
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func categoryPage(_ category: SettingsCategory) -> some View {
|
|
switch category {
|
|
case .pro:
|
|
SettingsFormPage { ProSettings() }
|
|
case .app:
|
|
SettingsFormPage {
|
|
AppSettings()
|
|
StartupSettings()
|
|
AppInfoSettings()
|
|
}
|
|
case .terminal:
|
|
SettingsFormPage {
|
|
TerminalSettings(
|
|
editConfiguration: { navigate(to: .terminalConfiguration($0)) }
|
|
)
|
|
}
|
|
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
|
|
|
|
var body: some View {
|
|
Form {
|
|
Section {
|
|
categoryButton(.pro)
|
|
}
|
|
|
|
Section {
|
|
ForEach(regularCategories) { category in
|
|
categoryButton(category)
|
|
}
|
|
}
|
|
|
|
if debugSectionEnabled {
|
|
Section {
|
|
Button(action: openLogging) {
|
|
HStack {
|
|
Label("Logging", systemImage: "doc.text")
|
|
Spacer()
|
|
Image(systemName: "chevron.right").foregroundStyle(.tertiary)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
LabeledContent {
|
|
Text(verbatim: DeviceCensorship.canDisplayTaiwanFlag ? "true" : "false")
|
|
} label: {
|
|
Label("Taiwan Flag Available", 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 {
|
|
case .pro:
|
|
false
|
|
#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
|
|
}
|
|
}
|
|
|
|
private struct ProLockedSettingsPage: View {
|
|
@Environment(AppModel.self) private var app
|
|
let title: LocalizedStringKey
|
|
let feature: ProFeature
|
|
|
|
var body: some View {
|
|
VStack(spacing: Metrics.Space.l) {
|
|
Spacer()
|
|
Image(systemName: "lock.fill")
|
|
.font(.system(size: 34))
|
|
.foregroundStyle(.tint)
|
|
Text(title).font(.title2.bold())
|
|
Text("This configuration is available with Termind Pro.")
|
|
.foregroundStyle(.secondary)
|
|
Button("View Termind Pro") { app.proAccess.presentPaywall(for: feature) }
|
|
.buttonStyle(.borderedProminent)
|
|
Spacer()
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
}
|
|
}
|
|
|
|
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.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("Appearance") {
|
|
Toggle("Show in Menu Bar", isOn: $showMetricsInMenuBar)
|
|
Picker(
|
|
"Theme",
|
|
selection: Binding(
|
|
get: { AppAppearance(rawValue: appearanceID) ?? .system },
|
|
set: { appearanceID = $0.rawValue }
|
|
)
|
|
) {
|
|
ForEach(AppAppearance.allCases) { appearance in
|
|
Text(appearance.displayName).tag(appearance)
|
|
}
|
|
}
|
|
|
|
Picker("Language", selection: languageSelection) {
|
|
ForEach(AppLanguage.allCases) { language in
|
|
Text(
|
|
language.displayName(
|
|
deviceRegion: Locale.autoupdatingCurrent.region?.identifier,
|
|
canDisplayTaiwanFlag: DeviceCensorship.canDisplayTaiwanFlag
|
|
)
|
|
).tag(language)
|
|
}
|
|
}
|
|
|
|
if selectedLanguage != launchLanguage {
|
|
LabeledContent {
|
|
Button("Relaunch Now") { AppRelauncher.relaunch() }
|
|
} label: {
|
|
Text("The new language will be used after Termind relaunches.")
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
Section {
|
|
Toggle(
|
|
"Automatically Rename Workspaces",
|
|
isOn: Binding(
|
|
get: { app.workspaceSettings.preferences.automaticallyRenamesWorkspaces },
|
|
set: { _ = app.workspaceSettings.setAutomaticRenaming($0) }
|
|
)
|
|
)
|
|
if let error = app.workspaceSettings.storageError {
|
|
Text(error).foregroundStyle(.orange)
|
|
}
|
|
} header: {
|
|
Text("Workspaces")
|
|
} footer: {
|
|
Text("New Workspaces with the same Host name use the next number, regardless of protocol.")
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct AppInfoSettings: View {
|
|
@Environment(AppModel.self) private var app
|
|
@AppStorage(SettingsKey.debugSectionEnabled) private var debugSectionEnabled = false
|
|
|
|
var body: some View {
|
|
Section("Version") {
|
|
LabeledContent {
|
|
Text(Bundle.termindDisplayVersion)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
} label: {
|
|
Text(verbatim: "App")
|
|
}
|
|
.contentShape(.rect)
|
|
.simultaneousGesture(
|
|
TapGesture(count: 3)
|
|
.onEnded { debugSectionEnabled.toggle() }
|
|
)
|
|
LabeledContent("Ghostty") {
|
|
Text(RuntimeVersions.ghostty).foregroundStyle(.secondary).textSelection(.enabled)
|
|
}
|
|
LabeledContent("libssh2") {
|
|
Text(RuntimeVersions.libssh2).foregroundStyle(.secondary).textSelection(.enabled)
|
|
}
|
|
LabeledContent("OpenSSL") {
|
|
Text(RuntimeVersions.openSSL).foregroundStyle(.secondary).textSelection(.enabled)
|
|
}
|
|
}
|
|
|
|
Section("Support") {
|
|
Link(destination: AppReviewController.reviewURL) {
|
|
Label("Rate on the App Store", systemImage: "star")
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
.environment(
|
|
\.openURL,
|
|
OpenURLAction { url in
|
|
app.appReview.recordManualReview()
|
|
return .systemAction(url)
|
|
}
|
|
)
|
|
Link(destination: URL(string: "https://termind.akinokaede.com/support/")!) {
|
|
Label("Support", systemImage: "questionmark.circle")
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
Link(destination: URL(string: "https://termind.akinokaede.com/privacy/")!) {
|
|
Label("Privacy Policy", systemImage: "hand.raised")
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct ProSettings: View {
|
|
@Environment(AppModel.self) private var app
|
|
|
|
var body: some View {
|
|
Group {
|
|
Section("Access") {
|
|
LabeledContent("Status") { statusLabel }
|
|
if let expirationLabel, let expirationDate {
|
|
LabeledContent(expirationLabel) {
|
|
Text(expirationDate, format: .dateTime.year().month().day().hour().minute())
|
|
}
|
|
}
|
|
}
|
|
|
|
Section("Termind Pro") {
|
|
Text(
|
|
"Choose annual access or a permanent unlock for the AI Assistant, terminal uploads, MCP, and iCloud sync."
|
|
)
|
|
.foregroundStyle(.secondary)
|
|
if app.proAccess.status.canPurchase {
|
|
Button("View Termind Pro") { app.proAccess.presentPaywall() }
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
if canManageSubscription {
|
|
Link(
|
|
"Manage Subscription",
|
|
destination: URL(string: "https://apps.apple.com/account/subscriptions")!)
|
|
}
|
|
Button("Restore Purchase") { Task { await app.proAccess.restorePurchases() } }
|
|
.disabled(app.proAccess.isProcessing)
|
|
if let message = app.proAccess.message {
|
|
Text(message).font(.caption).foregroundStyle(.orange)
|
|
}
|
|
}
|
|
|
|
Section("Options") {
|
|
includedFeature("Annual Subscription", systemImage: "calendar")
|
|
includedFeature("Permanent lifetime unlock", systemImage: "infinity")
|
|
includedFeature("Apple Family Sharing", systemImage: "person.3")
|
|
Text("AI provider usage and charges are separate from Termind Pro.")
|
|
.font(.caption).foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
|
|
private var canManageSubscription: Bool {
|
|
guard case .subscription(let subscription) = app.proAccess.status else { return false }
|
|
return subscription.ownership == .purchased
|
|
}
|
|
|
|
private var expirationDate: Date? {
|
|
switch app.proAccess.status {
|
|
case .subscription(let subscription): subscription.expiresAt
|
|
default: nil
|
|
}
|
|
}
|
|
|
|
private var expirationLabel: LocalizedStringKey? {
|
|
switch app.proAccess.status {
|
|
case .subscription(let subscription):
|
|
switch subscription.phase {
|
|
case .introductoryTrial: "Trial Ends"
|
|
case .subscribed: subscription.willAutoRenew == false ? "Access Ends" : "Renews"
|
|
case .gracePeriod: "Grace Period Ends"
|
|
case .billingRetry, .expired, .revoked: "Expired"
|
|
}
|
|
default:
|
|
nil
|
|
}
|
|
}
|
|
|
|
private func includedFeature(
|
|
_ title: LocalizedStringKey,
|
|
systemImage: String
|
|
) -> some View {
|
|
HStack(spacing: Metrics.Space.s) {
|
|
Image(systemName: systemImage)
|
|
.frame(width: 20, alignment: .center)
|
|
Text(title)
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var statusLabel: some View {
|
|
switch app.proAccess.status {
|
|
case .loading: Label("Checking…", systemImage: "arrow.triangle.2.circlepath")
|
|
case .free: Label("Free", systemImage: "person.crop.circle")
|
|
case .subscription(let subscription): subscriptionStatusLabel(subscription)
|
|
case .purchased(.purchased): Label("Lifetime", systemImage: "infinity")
|
|
case .purchased(.familyShared): Label("Lifetime · Family Shared", systemImage: "person.3.fill")
|
|
case .verificationFailed: Label("Verification Required", systemImage: "exclamationmark.shield")
|
|
case .unrestrictedForTesting: Label("Development Access", systemImage: "hammer")
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func subscriptionStatusLabel(_ subscription: ProSubscriptionStatus) -> some View {
|
|
switch subscription.phase {
|
|
case .introductoryTrial: Label("Free Trial", systemImage: "clock")
|
|
case .subscribed:
|
|
Label(
|
|
subscription.ownership == .familyShared ? "Annual · Family Shared" : "Annual Subscription",
|
|
systemImage: subscription.ownership == .familyShared ? "person.3.fill" : "checkmark.seal.fill")
|
|
case .gracePeriod: Label("Billing Grace Period", systemImage: "clock.badge.exclamationmark")
|
|
case .billingRetry: Label("Billing Issue", systemImage: "creditcard.trianglebadge.exclamationmark")
|
|
case .expired: Label("Subscription Expired", systemImage: "calendar.badge.exclamationmark")
|
|
case .revoked: Label("Subscription Revoked", systemImage: "xmark.seal")
|
|
}
|
|
}
|
|
}
|
|
|
|
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("Startup") {
|
|
Toggle(
|
|
isOn: Binding(
|
|
get: { loginItem.isEnabled },
|
|
set: { loginItem.setEnabled($0) }
|
|
)
|
|
) {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
|
|
Text("Open at Login")
|
|
Text(
|
|
"macOS opens Termind when you log in, which also starts the local MCP server if you have it enabled."
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Toggle("Open Hidden at Login", isOn: $opensQuietly)
|
|
.disabled(!loginItem.isEnabled)
|
|
|
|
if loginItem.requiresApproval {
|
|
LabeledContent {
|
|
Button("Open Login Items Settings") { loginItem.openSystemSettings() }
|
|
} label: {
|
|
Text("macOS is waiting for you to allow Termind in Login Items.")
|
|
.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("SSH") { SSHSettingsFields(model: app.sshSettings) }
|
|
Section("SFTP") { SFTPSettingsFields(model: app.sftpSettings) }
|
|
}
|
|
}
|
|
|
|
private struct TerminalSettings: View {
|
|
let editConfiguration: (TerminalCustomConfigurationScheme) -> 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) })
|
|
}
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let error = app.terminalSettings.storageError {
|
|
Text(error).foregroundStyle(.orange)
|
|
}
|
|
Section("Light") {
|
|
TerminalThemePicker(selection: binding(\.lightTheme), isDark: false)
|
|
Toggle("Custom Configuration", isOn: binding(\.lightCustomEnabled))
|
|
FormNavigationAction(title: "Edit Custom Configuration") {
|
|
editConfiguration(.light)
|
|
}
|
|
}
|
|
Section {
|
|
TerminalThemePicker(selection: binding(\.darkTheme), isDark: true)
|
|
Toggle("Custom Configuration", isOn: binding(\.darkCustomEnabled))
|
|
FormNavigationAction(title: "Edit Custom Configuration") {
|
|
editConfiguration(.dark)
|
|
}
|
|
} header: {
|
|
Text("Dark")
|
|
} footer: {
|
|
Text(
|
|
"Termind uses [Ghostty](https://ghostty.org) as its terminal engine. Custom configuration uses [Ghostty's configuration format](https://ghostty.org/docs/config/reference)."
|
|
)
|
|
}
|
|
Section("Font") {
|
|
TerminalFontFields(preferences: $font, families: TerminalFontCatalog.shared.families)
|
|
}
|
|
Section("Mouse") {
|
|
Toggle("Copy on Selection", isOn: binding(\.copyOnSelect))
|
|
Toggle(isOn: binding(\.pasteOnRightClick)) {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
|
|
Text("Paste on Right-Click")
|
|
Text(
|
|
"Right-click to paste. Hold Option to keep the usual right-click behavior. Applications that capture the mouse keep their mouse controls."
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|
|
Section("Integration") {
|
|
Toggle(isOn: binding(\.shellIntegration)) {
|
|
VStack(alignment: .leading, spacing: Metrics.Space.xxs) {
|
|
Text("Shell Integration")
|
|
Text(
|
|
"Keeps the remote working directory current, records confirmed commands, and provides detected distribution, kernel, and architecture details to AI features."
|
|
)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
|
|
Toggle("Show Terminal Title in Menu Bar", isOn: binding(\.remoteWindowTitleInMenuBar))
|
|
}
|
|
}
|
|
.onAppear {
|
|
TerminalFontCatalog.shared.refresh()
|
|
}
|
|
.onChange(of: scenePhase) { _, phase in
|
|
if phase == .active { TerminalFontCatalog.shared.refresh() }
|
|
}
|
|
}
|
|
}
|