import AppKit @MainActor final class TranslationSettingsController: NSViewController { private let keyField = NSSecureTextField() private let status = NSTextField(wrappingLabelWithString: "") private var statusIsError = false private var themeObservation: ThemeObservation? private let credentials: TranslationController.Credentials init(credentials: TranslationController.Credentials = .keychain) { self.credentials = credentials super.init(nibName: nil, bundle: nil) } required init?(coder: NSCoder) { fatalError("Conjure creates Translate settings programmatically") } override func loadView() { view = NSView() keyField.placeholderString = "OpenAI API key (saved in Keychain)" keyField.setAccessibilityLabel("OpenAI API key") let save = NSButton(title: "Save Key", target: self, action: #selector(saveKey)) let forget = NSButton(title: "Forget Key", target: self, action: #selector(forgetKey)) for button in [save, forget] { button.bezelStyle = .rounded button.contentTintColor = ThemePalette.accent } let controls = NSStackView(views: [keyField, save, forget]) controls.orientation = .horizontal controls.alignment = .centerY controls.spacing = 8 keyField.setContentHuggingPriority(.defaultLow, for: .horizontal) keyField.widthAnchor.constraint(greaterThanOrEqualToConstant: 220).isActive = true status.setAccessibilityLabel("Translation credential status and errors") let myMemory = NSTextField(wrappingLabelWithString: "MyMemory · No key · 500 bytes/request · 5,000 characters/day. Text is sent to MyMemory only when you translate; avoid sensitive content." ) let openAI = NSTextField(wrappingLabelWithString: "OpenAI · gpt-6-luna · Text and optional situation/context are sent to OpenAI only when you explicitly translate, not when advancing to context. Saving a key does not send your text. Your API key is stored in Keychain." ) for label in [status, myMemory, openAI] { label.textColor = ThemePalette.secondaryText } let stack = NSStackView(views: [controls, status, myMemory, openAI]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 12 stack.translatesAutoresizingMaskIntoConstraints = false view.addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: view.leadingAnchor), stack.trailingAnchor.constraint(equalTo: view.trailingAnchor), stack.topAnchor.constraint(equalTo: view.topAnchor), stack.bottomAnchor.constraint(equalTo: view.bottomAnchor), controls.widthAnchor.constraint(equalTo: stack.widthAnchor), ]) for label in [status, myMemory, openAI] { label.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true } themeObservation = ThemeObservation { [weak self] in save.contentTintColor = ThemePalette.accent forget.contentTintColor = ThemePalette.accent myMemory.textColor = ThemePalette.secondaryText openAI.textColor = ThemePalette.secondaryText guard let self else { return } self.status.textColor = self.statusIsError ? ThemePalette.error : ThemePalette.secondaryText } do { setStatus(try credentials.load() == nil ? "No OpenAI API key saved." : "OpenAI API key saved in Keychain.") } catch { setStatus(error.localizedDescription, isError: true) } } @objc private func saveKey() { let key = keyField.stringValue.trimmingCharacters(in: .whitespacesAndNewlines) guard !key.isEmpty, !key.contains(where: { $0.isNewline }) else { setStatus("Enter a valid API key before saving.", isError: true) return } do { try credentials.save(key) keyField.stringValue = "" setStatus("API key saved in Keychain.") NotificationCenter.default.post(name: Notification.Name("ConjureTranslationCredentialsChanged"), object: self) } catch { setStatus(error.localizedDescription, isError: true) } } @objc private func forgetKey() { do { try credentials.forget() keyField.stringValue = "" setStatus("API key removed.") NotificationCenter.default.post(name: Notification.Name("ConjureTranslationCredentialsChanged"), object: self) } catch { setStatus(error.localizedDescription, isError: true) } } private func setStatus(_ message: String, isError: Bool = false) { statusIsError = isError status.stringValue = message status.textColor = isError ? ThemePalette.error : ThemePalette.secondaryText } }