diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift index 7278fbf..b096efd 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ACP/ACPClient.swift @@ -311,6 +311,14 @@ public actor ACPClient { let result = try await sendRequest(method: "session/prompt", params: params) let dict = result?.dictValue ?? [:] let usage = dict["usage"] as? [String: Any] ?? [:] + // TODO(WS-8-Q1): Confirm wire field name once v0.13 Hermes is + // available. We tolerate camelCase + snake_case to match the rest + // of the ACP payload's mixed conventions; if Hermes routes the + // count through a `session/update` notification instead, this + // decode is a no-op and the ACPEvent path takes over. + let compression = (usage["compressionCount"] as? Int) + ?? (usage["compression_count"] as? Int) + ?? 0 statusMessage = "Ready" return ACPPromptResult( @@ -318,7 +326,8 @@ public actor ACPClient { inputTokens: usage["inputTokens"] as? Int ?? 0, outputTokens: usage["outputTokens"] as? Int ?? 0, thoughtTokens: usage["thoughtTokens"] as? Int ?? 0, - cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0 + cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0, + compressionCount: compression ) } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ACPMessages.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ACPMessages.swift index 8bd602f..54ccbb4 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ACPMessages.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ACPMessages.swift @@ -243,19 +243,32 @@ public struct ACPPromptResult: Sendable { public let outputTokens: Int public let thoughtTokens: Int public let cachedReadTokens: Int + /// Number of automatic context compactions Hermes has performed on this + /// session so far. v0.13+ — older Hermes hosts always return 0, which + /// the chat status bar treats as "hide chip". Optional in the wire + /// payload; folded into a non-optional `Int` here with a 0 default so + /// the rest of the pipeline doesn't need to nil-check. + // TODO(WS-8-Q1): Verify that v0.13 Hermes emits the count on + // `session/prompt`'s `usage` blob (assumed here). If it lands on a + // separate `session/update` notification instead, this becomes a new + // ACPEvent case + a branch in RichChatViewModel.handleACPEvent — wire + // shape is documented in the WS-8 plan as the bigger fix path. + public let compressionCount: Int public init( stopReason: String, inputTokens: Int, outputTokens: Int, thoughtTokens: Int, - cachedReadTokens: Int + cachedReadTokens: Int, + compressionCount: Int = 0 ) { self.stopReason = stopReason self.inputTokens = inputTokens self.outputTokens = outputTokens self.thoughtTokens = thoughtTokens self.cachedReadTokens = cachedReadTokens + self.compressionCount = compressionCount } } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesConfig.swift index fa52787..4b3d4f3 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/Packages/ScarfCore/Sources/ScarfCore/Services/HermesUpdaterCommandBuilder.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesUpdaterCommandBuilder.swift new file mode 100644 index 0000000..fde28ee --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesUpdaterCommandBuilder.swift @@ -0,0 +1,34 @@ +import Foundation + +/// Pure helpers that build argv arrays for `hermes update` invocations. +/// +/// Lives in ScarfCore so the eventual UI surface (Mac / iOS / remote) +/// shares flag selection. There is no in-app "Update Hermes" affordance +/// in v2.7.5 — Sparkle handles Scarf-self-update and `hermes update` is +/// invoked by users in their terminal — but capability-gated flag logic +/// is forward-compat plumbing that the future affordance will call. Each +/// helper is a `nonisolated static` pure function: no transport, no +/// MainActor, no mocking surface required. +public enum HermesUpdaterCommandBuilder { + /// Argv for an `hermes update` invocation, capability-gated. + /// + /// Pre-v0.12 hosts only had `update` (no flags). v0.12+ accepts + /// `--check` for preflight. v0.13+ accepts `--yes` / `-y` for + /// unattended runs (skips the interactive confirmation prompt). + /// Flags are silently dropped when the connected host can't honor + /// them so callers don't need to branch on capabilities themselves. + public static func updateArgv( + capabilities: HermesCapabilities, + unattended: Bool, + checkOnly: Bool + ) -> [String] { + var args: [String] = ["update"] + if checkOnly && capabilities.hasUpdateCheck { + args.append("--check") + } + if unattended && capabilities.hasUpdateNonInteractive { + args.append("--yes") + } + return args + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index f4e7ced..c80d3b9 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -229,6 +229,12 @@ public final class RichChatViewModel { public private(set) var acpOutputTokens = 0 public private(set) var acpThoughtTokens = 0 public private(set) var acpCachedReadTokens = 0 + /// Running count of context compactions Hermes has performed on this + /// session. Surfaced as the `🗜 ×N` chip in `SessionInfoBar` when > 0 + /// and `HermesCapabilities.hasContextCompressionCount` is true. Each + /// `session/prompt` response carries the latest server-side total, so + /// we replace (with a `max` guard) rather than accumulate. + public private(set) var acpCompressionCount = 0 /// Slash commands advertised by the ACP server via `available_commands_update`. public private(set) var acpCommands: [HermesSlashCommand] = [] @@ -608,6 +614,7 @@ public final class RichChatViewModel { acpErrorHint = nil acpErrorDetails = nil acpCachedReadTokens = 0 + acpCompressionCount = 0 acpCommands = [] projectScopedCommands = [] currentTurnStart = nil @@ -959,6 +966,13 @@ public final class RichChatViewModel { acpOutputTokens += response.outputTokens acpThoughtTokens += response.thoughtTokens acpCachedReadTokens += response.cachedReadTokens + // Compression count is a session-wide running total emitted by + // Hermes; each prompt response carries the latest value, so we + // replace rather than accumulate. The `max` guard tolerates + // pre-v0.13 hosts (which emit 0) being upgraded server-side + // mid-session — once a real number lands the count resumes from + // there rather than snapping back to 0. + acpCompressionCount = max(acpCompressionCount, response.compressionCount) isAgentWorking = false // v2.8 / Hermes v0.13 — Hermes runs the next `/queue`-deferred // prompt server-side now that this turn has settled. Drain the diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift index 3648370..4a08304 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0dViewModelsTests.swift @@ -162,6 +162,47 @@ import Foundation // start → false. #expect(vm.supportsCompress == false) #expect(vm.hasBroaderCommandMenu == false) + // v0.13: compression count starts at 0 so the SessionInfoBar chip + // stays hidden on fresh sessions. + #expect(vm.acpCompressionCount == 0) + } + + @Test @MainActor func richChatTracksCompressionCountFromPromptResults() { + let vm = RichChatViewModel(context: .local) + let response = ACPPromptResult( + stopReason: "end_turn", + inputTokens: 100, outputTokens: 50, + thoughtTokens: 20, cachedReadTokens: 10, + compressionCount: 3 + ) + vm.handleACPEvent(.promptComplete(sessionId: "s", response: response)) + #expect(vm.acpCompressionCount == 3) + + // Subsequent prompts overwrite (with a max guard) — the server + // emits a session-wide running total, not a per-prompt delta. + let next = ACPPromptResult( + stopReason: "end_turn", + inputTokens: 0, outputTokens: 0, + thoughtTokens: 0, cachedReadTokens: 0, + compressionCount: 5 + ) + vm.handleACPEvent(.promptComplete(sessionId: "s", response: next)) + #expect(vm.acpCompressionCount == 5) + + // A pre-v0.13 host mid-session emits 0; the max-guard keeps the + // last real value rather than snapping back. + let stale = ACPPromptResult( + stopReason: "end_turn", + inputTokens: 0, outputTokens: 0, + thoughtTokens: 0, cachedReadTokens: 0, + compressionCount: 0 + ) + vm.handleACPEvent(.promptComplete(sessionId: "s", response: stale)) + #expect(vm.acpCompressionCount == 5) + + // reset() clears the counter so a fresh session starts clean. + vm.reset() + #expect(vm.acpCompressionCount == 0) } @Test @MainActor func messageGroupDerivedProperties() { diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0eUpdaterTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0eUpdaterTests.swift new file mode 100644 index 0000000..c063028 --- /dev/null +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0eUpdaterTests.swift @@ -0,0 +1,87 @@ +import Testing +import Foundation +@testable import ScarfCore + +/// Pure-function matrix for `HermesUpdaterCommandBuilder.updateArgv`. The +/// builder degrades flags silently when the connected host can't honor +/// them, so the "is the right flag emitted on the right version?" matrix +/// is the meaningful test surface. +@Suite struct M0eUpdaterTests { + + // MARK: - Helpers + + private func caps(_ versionLine: String?) -> HermesCapabilities { + guard let line = versionLine else { return .empty } + return HermesCapabilities.parseLine(line) + } + + // MARK: - Pre-v0.12 (no flags supported) + + @Test func preV012_returnsBareUpdateRegardlessOfFlags() { + let pre = caps("Hermes Agent v0.11.0 (2026.4.23)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: pre, unattended: false, checkOnly: false + ) == ["update"]) + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: pre, unattended: true, checkOnly: false + ) == ["update"]) + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: pre, unattended: true, checkOnly: true + ) == ["update"]) + } + + @Test func unknownVersion_returnsBareUpdate() { + // No detected version means we can't guarantee any flag is + // honored; defensively emit the bare verb. + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: .empty, unattended: true, checkOnly: true + ) == ["update"]) + } + + // MARK: - v0.12 (--check supported, --yes is not) + + @Test func v012_checkOnly_emitsCheckFlag() { + let v012 = caps("Hermes Agent v0.12.0 (2026.4.30)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: v012, unattended: false, checkOnly: true + ) == ["update", "--check"]) + } + + @Test func v012_unattended_dropsYesFlag() { + // v0.12 doesn't honor --yes; the helper degrades silently. + let v012 = caps("Hermes Agent v0.12.0 (2026.4.30)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: v012, unattended: true, checkOnly: false + ) == ["update"]) + } + + @Test func v012_checkOnlyAndUnattended_emitsOnlyCheck() { + let v012 = caps("Hermes Agent v0.12.0 (2026.4.30)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: v012, unattended: true, checkOnly: true + ) == ["update", "--check"]) + } + + // MARK: - v0.13 (full flag support) + + @Test func v013_unattended_emitsYesFlag() { + let v013 = caps("Hermes Agent v0.13.0 (2026.5.7)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: v013, unattended: true, checkOnly: false + ) == ["update", "--yes"]) + } + + @Test func v013_checkOnlyAndUnattended_emitsBothFlags() { + let v013 = caps("Hermes Agent v0.13.0 (2026.5.7)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: v013, unattended: true, checkOnly: true + ) == ["update", "--check", "--yes"]) + } + + @Test func v013_neither_emitsBareUpdate() { + let v013 = caps("Hermes Agent v0.13.0 (2026.5.7)") + #expect(HermesUpdaterCommandBuilder.updateArgv( + capabilities: v013, unattended: false, checkOnly: false + ) == ["update"]) + } +} diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift index d943eba..d2de257 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/ScarfCoreSmokeTests.swift @@ -242,6 +242,15 @@ import Foundation thoughtTokens: 20, cachedReadTokens: 10 ) #expect(prompt.stopReason == "end_turn") + // v0.13: compressionCount has a 0 default for legacy callers. + #expect(prompt.compressionCount == 0) + + let v013Prompt = ACPPromptResult( + stopReason: "end_turn", inputTokens: 0, outputTokens: 0, + thoughtTokens: 0, cachedReadTokens: 0, + compressionCount: 7 + ) + #expect(v013Prompt.compressionCount == 7) } @Test func projectDashboardInitChain() { diff --git a/scarf/scarf/Core/Services/HermesFileService.swift b/scarf/scarf/Core/Services/HermesFileService.swift index 5d56911..11a8df1 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/Chat/Views/ChatTranscriptPane.swift b/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift index 301a5b5..97a190d 100644 --- a/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift +++ b/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift @@ -11,6 +11,7 @@ struct ChatTranscriptPane: View { @Bindable var chatViewModel: ChatViewModel var onSend: (String, [ChatImageAttachment]) -> Void var isEnabled: Bool + @Environment(\.hermesCapabilities) private var capabilitiesStore var body: some View { VStack(spacing: 0) { @@ -20,11 +21,13 @@ struct ChatTranscriptPane: View { acpInputTokens: richChat.acpInputTokens, acpOutputTokens: richChat.acpOutputTokens, acpThoughtTokens: richChat.acpThoughtTokens, + acpCompressionCount: richChat.acpCompressionCount, projectName: chatViewModel.currentProjectName, gitBranch: chatViewModel.currentGitBranch, activeGoal: richChat.activeGoal, onClearGoal: { chatViewModel.sendText("/goal --clear") }, - queuedPrompts: richChat.queuedPrompts + queuedPrompts: richChat.queuedPrompts, + capabilities: capabilitiesStore?.capabilities ?? .empty ) Divider() diff --git a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift index b448207..7563cde 100644 --- a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift +++ b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift @@ -9,6 +9,11 @@ struct SessionInfoBar: View { var acpInputTokens: Int = 0 var acpOutputTokens: Int = 0 var acpThoughtTokens: Int = 0 + /// Number of context compactions Hermes has run on this session. v0.13+ + /// surface — capability-gated by the bar so pre-v0.13 hosts never see + /// the chip even if a stale value somehow trickles through. Defaults + /// to 0 so existing callers and previews don't need to be updated. + var acpCompressionCount: Int = 0 /// Name of the Scarf project this session is attributed to, when /// applicable. Nil for plain global chats. Drives the folder-chip /// indicator rendered before the session title. Resolved by @@ -31,6 +36,10 @@ struct SessionInfoBar: View { /// Local mirror of prompts queued via `/queue …` (Hermes v0.13). /// Empty list hides the chip. var queuedPrompts: [HermesQueuedPrompt] = [] + /// Capability snapshot for v0.13+ surfaces. Defaulted so previews and + /// pre-v0.13 hosts render the v2.7.5 layout unchanged. Coordinated + /// with WS-2 — both WSes add `capabilities` to this view. + var capabilities: HermesCapabilities = .empty /// Active Hermes profile name (issue #50). Resolved on each body /// re-evaluation; the resolver caches for 5s so this is cheap. @@ -143,6 +152,21 @@ struct SessionInfoBar: View { Label("\(formatTokens(reasonToks)) reasoning", systemImage: "brain") } + // v0.13: Hermes surfaces a running count of automatic + // context compactions. Render only when the host is on + // v0.13+ AND the count is non-zero, so a pre-v0.13 host + // (which always reports 0) sees no chip, and a v0.13 host + // sees the chip the first time the agent compacts. + if capabilities.hasContextCompressionCount && acpCompressionCount > 0 { + Label( + "×\(acpCompressionCount)", + systemImage: "arrow.down.right.and.arrow.up.left" + ) + .scarfStyle(.caption) + .foregroundStyle(ScarfColor.foregroundMuted) + .help("Hermes auto-compacted this session's context \(acpCompressionCount) time\(acpCompressionCount == 1 ? "" : "s")") + } + if let cost = session.displayCostUSD { let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4))) Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle") diff --git a/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift b/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift index 5cb6e43..3e39c09 100644 --- a/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift +++ b/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift @@ -100,7 +100,16 @@ private struct SlashCommandRow: View { .fontWeight(.semibold) .foregroundStyle(isSelected ? ScarfColor.accentActive : ScarfColor.foregroundPrimary) if let hint = command.argumentHint { - Text("<\(hint)>") + // v0.13: Hermes may emit hints already wrapped in + // brackets (e.g. `[name]` for the optional `/new + // ` argument exposed by `hasNewWithSessionName`). + // Avoid double-wrapping — bracketed hints pass through + // verbatim while older `guidance`-style hints (no + // brackets) still render as ``. + let display = hint.hasPrefix("<") || hint.hasPrefix("[") + ? hint + : "<\(hint)>" + Text(display) .font(ScarfFont.monoSmall) .foregroundStyle(ScarfColor.foregroundFaint) } diff --git a/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift b/scarf/scarf/Features/Settings/ViewModels/SettingsViewModel.swift index 8a6db85..4090972 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 @@ -168,6 +195,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) + } }