mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
feat(settings): v0.13 polish — redaction hint, display.language picker, xAI cloning badge (WS-8)
Three Settings-tab surfaces tracking v0.13 release notes: - **Redaction default-flip awareness** (Advanced → Caching & Redaction): inline hint below the existing toggle whose copy depends on `HermesCapabilities.isV013OrLater`. v0.13 flipped the server-side default from OFF (v0.12) to ON, but Scarf's parser still treats "absent key" as `false`. Hint disambiguates so users on v0.13 hosts understand redaction is on server-side even when the toggle reads OFF. - **`display.language` picker** (General → Locale): 8-option enum (`""` default + en/zh/ja/de/es/fr/uk/tr) capability-gated on `hasDisplayLanguage`. Persists via `hermes config set display.language <code>`. Empty string preserves "no key" semantics (Hermes-default English); explicit `en` pins it. Required a small `optionLabel:` overload on `PickerRow` so non-English labels (中文 / 日本語 / etc.) render alongside their codes. - **xAI Custom Voices badge** (Voice → Text-to-Speech): adds `xai` to the TTS provider picker (un-gated — xAI TTS shipped earlier), exposes Voice ID + Model fields, and renders a "Cloning supported" ScarfBadge gated on `hasXAIVoiceCloning`. Hint copy points at `hermes voice` for cloned-voice management since Scarf has no in-app surface for that yet (out-of-scope for v2.8). Capability gates: `isV013OrLater` (hint discriminator), `hasDisplayLanguage` (picker), `hasXAIVoiceCloning` (badge). Pre-v0.13 hosts see the v2.7.5 layout unchanged. `TODO(WS-8-Q2)` flags the assumed xAI YAML keys (`tts.xai.voice_id` / `tts.xai.model` mirroring elevenlabs) for grep-verify against `~/.hermes/hermes-agent/hermes_cli/voice/tts.py`. iOS deferred to v2.9 (Q4): `Scarf iOS` Settings is read-mostly and doesn't have a write surface for either the language picker or the xAI fields. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 <code>`. 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: ""
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (`<provider>.voice_id` + `<provider>.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 {
|
||||
|
||||
@@ -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) }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user