From f19f19cd56a5b9ac85c8abd51c1e318f6141b271 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 18:58:58 +0200 Subject: [PATCH 1/3] feat(chat): surface v0.13 compression count + bracket-aware slash hint (WS-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two small chat-surface additions tracking Hermes v0.13: - Plumb a `compressionCount` field through `ACPPromptResult` and `RichChatViewModel.acpCompressionCount` so `SessionInfoBar` can render a `🗜 ×N` chip next to the token counter when the agent has performed context compactions. Capability-gated on `HermesCapabilities.hasContextCompressionCount` and `count > 0` so pre-v0.13 hosts (which always emit 0) and fresh sessions never see the chip. Wire decode tolerates camelCase + snake_case; `TODO(WS-8-Q1)` flags the assumption that the field rides on `usage` — if v0.13 emits via a separate `session/update` notification the bigger fix is described in the WS-8 plan. - Slash-menu argument hint is now bracket-aware: hints starting with `<` or `[` pass through verbatim, others wrap as ``. v0.13's `/new [name]` ships through unchanged without rendering as `<[name]>`. No flag check at the renderer — agent payload is the source of truth. Coordination with WS-2: both WSes touch `SessionInfoBar`. WS-2 owns the queue chip on the left half; this WS owns the compression chip on the right half. The added `capabilities` parameter is shared — kept additive so WS-2's later merge produces no file-level conflict. Tests: extends `M0dViewModelsTests` (compression count tracking + reset semantics) and `ScarfCoreSmokeTests` (decode default + explicit v0.13 init path). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/ScarfCore/ACP/ACPClient.swift | 11 ++++- .../ScarfCore/Models/ACPMessages.swift | 15 ++++++- .../ViewModels/RichChatViewModel.swift | 14 +++++++ .../ScarfCoreTests/M0dViewModelsTests.swift | 41 +++++++++++++++++++ .../ScarfCoreTests/ScarfCoreSmokeTests.swift | 9 ++++ .../Chat/Views/ChatTranscriptPane.swift | 5 ++- .../Features/Chat/Views/SessionInfoBar.swift | 25 +++++++++++ .../Chat/Views/SlashCommandMenu.swift | 11 ++++- 8 files changed, 127 insertions(+), 4 deletions(-) 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/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index 5debd36..4742902 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] = [] @@ -468,6 +474,7 @@ public final class RichChatViewModel { acpErrorHint = nil acpErrorDetails = nil acpCachedReadTokens = 0 + acpCompressionCount = 0 acpCommands = [] projectScopedCommands = [] currentTurnStart = nil @@ -811,6 +818,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 buildMessageGroups() // Final position after the prompt settles. Catches fast responses 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/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/Features/Chat/Views/ChatTranscriptPane.swift b/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift index eaa8294..eb20766 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,8 +21,10 @@ struct ChatTranscriptPane: View { acpInputTokens: richChat.acpInputTokens, acpOutputTokens: richChat.acpOutputTokens, acpThoughtTokens: richChat.acpThoughtTokens, + acpCompressionCount: richChat.acpCompressionCount, projectName: chatViewModel.currentProjectName, - gitBranch: chatViewModel.currentGitBranch + gitBranch: chatViewModel.currentGitBranch, + capabilities: capabilitiesStore?.capabilities ?? .empty ) Divider() diff --git a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift index 33a2d5e..7b83c79 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 @@ -20,6 +25,11 @@ struct SessionInfoBar: View { /// name. Nil for non-project chats and for projects that aren't /// git repos. var gitBranch: String? = nil + /// 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; whichever + /// lands first establishes the prop. + 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. @@ -96,6 +106,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 22eb33f..ea6fc8c 100644 --- a/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift +++ b/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift @@ -87,7 +87,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) } From 5877bf65193e6af41eb5f9a7859846bf18d0eff8 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 18:59:12 +0200 Subject: [PATCH 2/3] feat(updater): forward-compat HermesUpdaterCommandBuilder for hermes update --yes (WS-8) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-function helper that builds argv arrays for `hermes update`, gated on `HermesCapabilities`. Pre-v0.12 → bare `update`; v0.12+ honors `--check`; v0.13+ honors `--yes` for unattended runs. No in-app "Update Hermes" affordance ships in v2.7.5 — Sparkle handles Scarf-self-update and `hermes update` is invoked by users in their terminal. This is forward-compat plumbing so the eventual UI surface shares flag selection across Mac / iOS / remote without re-deriving from scratch. Test matrix in `M0eUpdaterTests` covers all six combinations (pre-v0.12, v0.12 ± unattended ± check, v0.13 ± unattended ± check) plus an empty-capabilities fallback. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../HermesUpdaterCommandBuilder.swift | 34 ++++++++ .../ScarfCoreTests/M0eUpdaterTests.swift | 87 +++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Services/HermesUpdaterCommandBuilder.swift create mode 100644 scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M0eUpdaterTests.swift 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/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"]) + } +} From 0f78856e6e2eb6ac509f6a400e88a4cd4cc91785 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 9 May 2026 18:59:38 +0200 Subject: [PATCH 3/3] =?UTF-8?q?feat(settings):=20v0.13=20polish=20?= =?UTF-8?q?=E2=80=94=20redaction=20hint,=20display.language=20picker,=20xA?= =?UTF-8?q?I=20cloning=20badge=20(WS-8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `. 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) --- .../ScarfCore/Models/HermesConfig.swift | 33 ++++++++++++++++--- .../Core/Services/HermesFileService.swift | 13 ++++++-- .../ViewModels/SettingsViewModel.swift | 33 ++++++++++++++++++- .../Views/Components/SettingsComponents.swift | 24 +++++++++++++- .../Settings/Views/Tabs/AdvancedTab.swift | 26 +++++++++++++++ .../Settings/Views/Tabs/GeneralTab.swift | 15 +++++++++ .../Settings/Views/Tabs/VoiceTab.swift | 32 ++++++++++++++++++ 7 files changed, 168 insertions(+), 8 deletions(-) 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) + } }