diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift index a62ccef..8eb03b2 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift @@ -36,6 +36,13 @@ public struct DisplaySettings: Sendable, Equatable { public var toolProgressCommand: Bool public var toolPreviewLength: Int public var busyInputMode: String // e.g. "interrupt" + /// Static-message translation language. v0.13+. Empty string means + /// "follow Hermes default" — the picker collapses both empty-string + /// and `"en"` to "English" in display, but only writes a value when + /// the user explicitly picks one. Persisted via + /// `hermes config set display.language `. Supported values per + /// v0.13 release notes: `en`, `zh`, `ja`, `de`, `es`, `fr`, `uk`, `tr`. + public var language: String public init( @@ -46,7 +53,8 @@ public struct DisplaySettings: Sendable, Equatable { inlineDiffs: Bool, toolProgressCommand: Bool, toolPreviewLength: Int, - busyInputMode: String + busyInputMode: String, + language: String = "" ) { self.skin = skin self.compact = compact @@ -56,6 +64,7 @@ public struct DisplaySettings: Sendable, Equatable { self.toolProgressCommand = toolProgressCommand self.toolPreviewLength = toolPreviewLength self.busyInputMode = busyInputMode + self.language = language } public nonisolated static let empty = DisplaySettings( skin: "default", @@ -65,7 +74,8 @@ public struct DisplaySettings: Sendable, Equatable { inlineDiffs: true, toolProgressCommand: false, toolPreviewLength: 0, - busyInputMode: "interrupt" + busyInputMode: "interrupt", + language: "" ) } @@ -190,6 +200,15 @@ public struct VoiceSettings: Sendable, Equatable { public var ttsOpenAIVoice: String public var ttsNeuTTSModel: String public var ttsNeuTTSDevice: String + /// xAI TTS voice identifier. v0.13+ — xAI shipped TTS earlier but the + /// custom-voice / cloning surface is the v0.13 add-on. + // TODO(WS-8-Q2): Confirm key name vs `tts.xai.voice` / + // `tts.xai.voice_id` / a top-level `tts.xai_voice` once a v0.13 + // host is on hand. The setter / YAML reader follow whatever this + // field name implies. + public var ttsXAIVoiceID: String + /// xAI TTS model identifier. v0.13+. Mirrors the elevenlabs shape. + public var ttsXAIModel: String // STT public var sttEnabled: Bool @@ -217,7 +236,9 @@ public struct VoiceSettings: Sendable, Equatable { sttLocalModel: String, sttLocalLanguage: String, sttOpenAIModel: String, - sttMistralModel: String + sttMistralModel: String, + ttsXAIVoiceID: String = "", + ttsXAIModel: String = "" ) { self.recordKey = recordKey self.maxRecordingSeconds = maxRecordingSeconds @@ -230,6 +251,8 @@ public struct VoiceSettings: Sendable, Equatable { self.ttsOpenAIVoice = ttsOpenAIVoice self.ttsNeuTTSModel = ttsNeuTTSModel self.ttsNeuTTSDevice = ttsNeuTTSDevice + self.ttsXAIVoiceID = ttsXAIVoiceID + self.ttsXAIModel = ttsXAIModel self.sttEnabled = sttEnabled self.sttProvider = sttProvider self.sttLocalModel = sttLocalModel @@ -254,7 +277,9 @@ public struct VoiceSettings: Sendable, Equatable { sttLocalModel: "base", sttLocalLanguage: "", sttOpenAIModel: "whisper-1", - sttMistralModel: "voxtral-mini-latest" + sttMistralModel: "voxtral-mini-latest", + ttsXAIVoiceID: "", + ttsXAIModel: "" ) } diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 3938d09..6161250 100644 --- a/scarf/scarf/Core/Services/HermesFileService.swift +++ b/scarf/scarf/Core/Services/HermesFileService.swift @@ -84,7 +84,11 @@ struct HermesFileService: Sendable { inlineDiffs: bool("display.inline_diffs", default: true), toolProgressCommand: bool("display.tool_progress_command", default: false), toolPreviewLength: int("display.tool_preview_length", default: 0), - busyInputMode: str("display.busy_input_mode", default: "interrupt") + busyInputMode: str("display.busy_input_mode", default: "interrupt"), + // v0.13: empty default means "key absent — agent uses its own + // default" (English). The picker writes a real value when the + // user explicitly chooses one. + language: str("display.language", default: "") ) let terminal = TerminalSettings( @@ -131,7 +135,12 @@ struct HermesFileService: Sendable { sttLocalModel: str("stt.local.model", default: "base"), sttLocalLanguage: str("stt.local.language"), sttOpenAIModel: str("stt.openai.model", default: "whisper-1"), - sttMistralModel: str("stt.mistral.model", default: "voxtral-mini-latest") + sttMistralModel: str("stt.mistral.model", default: "voxtral-mini-latest"), + // TODO(WS-8-Q2): Verify key names. Mirroring the elevenlabs + // shape (`.voice_id` + `.model`); v0.13 + // source might use `tts.xai.voice` or `tts.xai.model_id`. + ttsXAIVoiceID: str("tts.xai.voice_id"), + ttsXAIModel: str("tts.xai.model") ) func aux(_ name: String) -> AuxiliaryModel { diff --git a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift index 369edb3..788832a 100644 --- a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift +++ b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift @@ -29,8 +29,31 @@ final class SettingsViewModel { // that no-ops on older hosts is low compared to gating overhead. var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh", "vercel"] var browserBackends = ["browseruse", "firecrawl", "local"] - var ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts", "piper"] + // v0.13: `xai` joins the TTS provider list. xAI shipped TTS earlier + // (v0.12) but the v0.13 add-on is custom voice cloning — see + // `HermesCapabilities.hasXAIVoiceCloning` and the badge in VoiceTab. + // The provider option itself is ungated so pre-v0.13 hosts with xAI + // keys can still pick it. + var ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts", "piper", "xai"] var sttProviders = ["local", "groq", "openai", "mistral"] + /// Static-message translation languages honored by Hermes v0.13's + /// `display.language` key. The first row's empty value writes no + /// key — equivalent to "Hermes default" — while explicit `en` writes + /// the code so users who care about determinism can pin it. Keep the + /// label list in sync with the Hermes v0.13 release notes; new + /// languages should be appended in alphabetical order by display + /// label so the picker stays scannable. + var displayLanguages: [(code: String, label: String)] = [ + ("", "English (default)"), + ("en", "English"), + ("zh", "中文 (Chinese)"), + ("ja", "日本語 (Japanese)"), + ("de", "Deutsch (German)"), + ("es", "Español (Spanish)"), + ("fr", "Français (French)"), + ("uk", "Українська (Ukrainian)"), + ("tr", "Türkçe (Turkish)"), + ] var memoryProviders = ["", "honcho", "openviking", "mem0", "hindsight", "holographic", "retaindb", "byterover", "supermemory"] var saveMessage: String? var isLoading = false @@ -104,6 +127,10 @@ final class SettingsViewModel { func setToolProgressCommand(_ value: Bool) { setSetting("display.tool_progress_command", value: value ? "true" : "false") } func setToolPreviewLength(_ value: Int) { setSetting("display.tool_preview_length", value: String(value)) } func setBusyInputMode(_ value: String) { setSetting("display.busy_input_mode", value: value) } + /// v0.13: `display.language` for static-message translations. Empty + /// string writes "" via `hermes config set` which Hermes treats as + /// "use default"; explicit codes pin the language. + func setDisplayLanguage(_ value: String) { setSetting("display.language", value: value) } // MARK: - Agent @@ -158,6 +185,10 @@ final class SettingsViewModel { func setTTSOpenAIVoice(_ value: String) { setSetting("tts.openai.voice", value: value) } func setTTSNeuTTSModel(_ value: String) { setSetting("tts.neutts.model", value: value) } func setTTSNeuTTSDevice(_ value: String) { setSetting("tts.neutts.device", value: value) } + // v0.13: xAI TTS / Custom Voices. TODO(WS-8-Q2): grep-verify key + // names against `~/.hermes/hermes-agent/hermes_cli/voice/tts.py`. + func setTTSXAIVoiceID(_ value: String) { setSetting("tts.xai.voice_id", value: value) } + func setTTSXAIModel(_ value: String) { setSetting("tts.xai.model", value: value) } func setSTTEnabled(_ value: Bool) { setSetting("stt.enabled", value: value ? "true" : "false") } func setSTTProvider(_ value: String) { setSetting("stt.provider", value: value) } func setSTTLocalModel(_ value: String) { setSetting("stt.local.model", value: value) } diff --git a/scarf/scarf/Features/Settings/Views/Components/SettingsComponents.swift b/scarf/scarf/Features/Settings/Views/Components/SettingsComponents.swift index e197376..119d7b4 100644 --- a/scarf/scarf/Features/Settings/Views/Components/SettingsComponents.swift +++ b/scarf/scarf/Features/Settings/Views/Components/SettingsComponents.swift @@ -152,8 +152,23 @@ struct PickerRow: View { let label: String let selection: String let options: [String] + let optionLabel: ((String) -> String)? let onChange: (String) -> Void + init( + label: String, + selection: String, + options: [String], + optionLabel: ((String) -> String)? = nil, + onChange: @escaping (String) -> Void + ) { + self.label = label + self.selection = selection + self.options = options + self.optionLabel = optionLabel + self.onChange = onChange + } + var body: some View { HStack { SettingsRowLabel(label: label) @@ -162,7 +177,7 @@ struct PickerRow: View { set: { onChange($0) } )) { ForEach(options, id: \.self) { option in - Text(option.isEmpty ? "(none)" : option).tag(option) + Text(displayLabel(for: option)).tag(option) } } .frame(maxWidth: 250) @@ -170,6 +185,13 @@ struct PickerRow: View { } .settingsRowChrome() } + + private func displayLabel(for option: String) -> String { + if let mapper = optionLabel { + return mapper(option) + } + return option.isEmpty ? "(none)" : option + } } struct ToggleRow: View { diff --git a/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift index d7109ab..950ab32 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/AdvancedTab.swift @@ -131,6 +131,8 @@ struct AdvancedTab: View { isOn: viewModel.config.redactionEnabled ) { viewModel.setSetting("redaction.enabled", value: $0 ? "true" : "false") } + redactionDefaultsHint + ToggleRow( label: "Runtime metadata footer", isOn: viewModel.config.runtimeMetadataFooter @@ -138,6 +140,30 @@ struct AdvancedTab: View { } } + /// Inline hint below the redaction toggle. The server-side default + /// flipped from OFF (v0.12) to ON (v0.13), but Scarf's parser still + /// reads "absent key" as `false` — meaning a v0.13 host with no + /// explicit key in `config.yaml` shows the toggle OFF while the + /// agent treats redaction as ON. Hint copy disambiguates so users + /// can tell what's actually happening server-side. + @ViewBuilder + private var redactionDefaultsHint: some View { + let isV013 = capabilitiesStore?.capabilities.isV013OrLater ?? false + HStack { + Text("") + .font(.caption) + .frame(width: 160, alignment: .trailing) + Text(isV013 + ? "Recommended: ON. Hermes v0.13+ defaults to redacting secrets unless you opt out." + : "Default OFF in Hermes v0.12. Toggle ON to redact secrets in logs and shares.") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + } + private var backupSection: some View { SettingsSection(title: "Backup & Restore", icon: "externaldrive") { HStack { diff --git a/scarf/scarf/Features/Settings/Views/Tabs/GeneralTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/GeneralTab.swift index 05c097b..0940ab1 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/GeneralTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/GeneralTab.swift @@ -7,6 +7,7 @@ import ScarfCore struct GeneralTab: View { @Bindable var viewModel: SettingsViewModel @Environment(AppCoordinator.self) private var coordinator + @Environment(\.hermesCapabilities) private var capabilitiesStore var body: some View { SettingsSection(title: "Model", icon: "cpu") { @@ -39,6 +40,20 @@ struct GeneralTab: View { SettingsSection(title: "Locale", icon: "globe.americas") { EditableTextField(label: "Timezone (IANA)", value: viewModel.config.timezone) { viewModel.setTimezone($0) } + // v0.13: `display.language` picker. Hidden on pre-v0.13 hosts + // because writing the key would no-op silently. Two "English" + // entries by design — empty string preserves "no key" semantics + // (Hermes-default), explicit `en` pins it. + if capabilitiesStore?.capabilities.hasDisplayLanguage == true { + PickerRow( + label: "Display language", + selection: viewModel.config.display.language, + options: viewModel.displayLanguages.map(\.code), + optionLabel: { code in + viewModel.displayLanguages.first { $0.code == code }?.label ?? code + } + ) { viewModel.setDisplayLanguage($0) } + } } UpdatesSection() diff --git a/scarf/scarf/Features/Settings/Views/Tabs/VoiceTab.swift b/scarf/scarf/Features/Settings/Views/Tabs/VoiceTab.swift index ee85d6c..6a1a47f 100644 --- a/scarf/scarf/Features/Settings/Views/Tabs/VoiceTab.swift +++ b/scarf/scarf/Features/Settings/Views/Tabs/VoiceTab.swift @@ -1,9 +1,11 @@ import SwiftUI import ScarfCore +import ScarfDesign /// Voice tab — push-to-talk + TTS + STT provider settings. struct VoiceTab: View { @Bindable var viewModel: SettingsViewModel + @Environment(\.hermesCapabilities) private var capabilitiesStore var body: some View { SettingsSection(title: "Push-to-Talk", icon: "mic") { @@ -28,6 +30,16 @@ struct VoiceTab: View { case "neutts": EditableTextField(label: "Model", value: viewModel.config.voice.ttsNeuTTSModel) { viewModel.setTTSNeuTTSModel($0) } PickerRow(label: "Device", selection: viewModel.config.voice.ttsNeuTTSDevice, options: ["cpu", "cuda"]) { viewModel.setTTSNeuTTSDevice($0) } + case "xai": + // v0.13: xAI TTS surface. Voice ID + Model are always + // visible (xAI TTS shipped earlier); the cloning-supported + // badge is gated on `hasXAIVoiceCloning` so pre-v0.13 hosts + // see the input rows but no cloning advertisement. + EditableTextField(label: "Voice ID", value: viewModel.config.voice.ttsXAIVoiceID) { viewModel.setTTSXAIVoiceID($0) } + EditableTextField(label: "Model", value: viewModel.config.voice.ttsXAIModel) { viewModel.setTTSXAIModel($0) } + if capabilitiesStore?.capabilities.hasXAIVoiceCloning == true { + xaiCloningBadge + } default: EmptyView() } @@ -49,4 +61,24 @@ struct VoiceTab: View { } } } + + /// Inline hint chip+caption shown below xAI's Voice ID + Model fields + /// on v0.13+. References `hermes voice` because Scarf doesn't manage + /// cloned voices in-app yet — the badge is discovery-only. Out-of-scope + /// for v2.8: an in-app cloned-voice manager (would be its own feature). + @ViewBuilder + private var xaiCloningBadge: some View { + HStack(alignment: .center, spacing: 8) { + Text("") + .font(.caption) + .frame(width: 160, alignment: .trailing) + ScarfBadge("Cloning supported", kind: .info) + Text("Manage cloned voices in your terminal: `hermes voice` (xAI subcommands).") + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, 12) + .padding(.vertical, 4) + } }