mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f78856e6e | |||
| 5877bf6519 | |||
| f19f19cd56 | |||
| 3e470c7155 |
@@ -311,6 +311,14 @@ public actor ACPClient {
|
|||||||
let result = try await sendRequest(method: "session/prompt", params: params)
|
let result = try await sendRequest(method: "session/prompt", params: params)
|
||||||
let dict = result?.dictValue ?? [:]
|
let dict = result?.dictValue ?? [:]
|
||||||
let usage = dict["usage"] as? [String: Any] ?? [:]
|
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"
|
statusMessage = "Ready"
|
||||||
return ACPPromptResult(
|
return ACPPromptResult(
|
||||||
@@ -318,7 +326,8 @@ public actor ACPClient {
|
|||||||
inputTokens: usage["inputTokens"] as? Int ?? 0,
|
inputTokens: usage["inputTokens"] as? Int ?? 0,
|
||||||
outputTokens: usage["outputTokens"] as? Int ?? 0,
|
outputTokens: usage["outputTokens"] as? Int ?? 0,
|
||||||
thoughtTokens: usage["thoughtTokens"] as? Int ?? 0,
|
thoughtTokens: usage["thoughtTokens"] as? Int ?? 0,
|
||||||
cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0
|
cachedReadTokens: usage["cachedReadTokens"] as? Int ?? 0,
|
||||||
|
compressionCount: compression
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -243,19 +243,32 @@ public struct ACPPromptResult: Sendable {
|
|||||||
public let outputTokens: Int
|
public let outputTokens: Int
|
||||||
public let thoughtTokens: Int
|
public let thoughtTokens: Int
|
||||||
public let cachedReadTokens: 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(
|
public init(
|
||||||
stopReason: String,
|
stopReason: String,
|
||||||
inputTokens: Int,
|
inputTokens: Int,
|
||||||
outputTokens: Int,
|
outputTokens: Int,
|
||||||
thoughtTokens: Int,
|
thoughtTokens: Int,
|
||||||
cachedReadTokens: Int
|
cachedReadTokens: Int,
|
||||||
|
compressionCount: Int = 0
|
||||||
) {
|
) {
|
||||||
self.stopReason = stopReason
|
self.stopReason = stopReason
|
||||||
self.inputTokens = inputTokens
|
self.inputTokens = inputTokens
|
||||||
self.outputTokens = outputTokens
|
self.outputTokens = outputTokens
|
||||||
self.thoughtTokens = thoughtTokens
|
self.thoughtTokens = thoughtTokens
|
||||||
self.cachedReadTokens = cachedReadTokens
|
self.cachedReadTokens = cachedReadTokens
|
||||||
|
self.compressionCount = compressionCount
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -36,6 +36,13 @@ public struct DisplaySettings: Sendable, Equatable {
|
|||||||
public var toolProgressCommand: Bool
|
public var toolProgressCommand: Bool
|
||||||
public var toolPreviewLength: Int
|
public var toolPreviewLength: Int
|
||||||
public var busyInputMode: String // e.g. "interrupt"
|
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(
|
public init(
|
||||||
@@ -46,7 +53,8 @@ public struct DisplaySettings: Sendable, Equatable {
|
|||||||
inlineDiffs: Bool,
|
inlineDiffs: Bool,
|
||||||
toolProgressCommand: Bool,
|
toolProgressCommand: Bool,
|
||||||
toolPreviewLength: Int,
|
toolPreviewLength: Int,
|
||||||
busyInputMode: String
|
busyInputMode: String,
|
||||||
|
language: String = ""
|
||||||
) {
|
) {
|
||||||
self.skin = skin
|
self.skin = skin
|
||||||
self.compact = compact
|
self.compact = compact
|
||||||
@@ -56,6 +64,7 @@ public struct DisplaySettings: Sendable, Equatable {
|
|||||||
self.toolProgressCommand = toolProgressCommand
|
self.toolProgressCommand = toolProgressCommand
|
||||||
self.toolPreviewLength = toolPreviewLength
|
self.toolPreviewLength = toolPreviewLength
|
||||||
self.busyInputMode = busyInputMode
|
self.busyInputMode = busyInputMode
|
||||||
|
self.language = language
|
||||||
}
|
}
|
||||||
public nonisolated static let empty = DisplaySettings(
|
public nonisolated static let empty = DisplaySettings(
|
||||||
skin: "default",
|
skin: "default",
|
||||||
@@ -65,7 +74,8 @@ public struct DisplaySettings: Sendable, Equatable {
|
|||||||
inlineDiffs: true,
|
inlineDiffs: true,
|
||||||
toolProgressCommand: false,
|
toolProgressCommand: false,
|
||||||
toolPreviewLength: 0,
|
toolPreviewLength: 0,
|
||||||
busyInputMode: "interrupt"
|
busyInputMode: "interrupt",
|
||||||
|
language: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +200,15 @@ public struct VoiceSettings: Sendable, Equatable {
|
|||||||
public var ttsOpenAIVoice: String
|
public var ttsOpenAIVoice: String
|
||||||
public var ttsNeuTTSModel: String
|
public var ttsNeuTTSModel: String
|
||||||
public var ttsNeuTTSDevice: 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
|
// STT
|
||||||
public var sttEnabled: Bool
|
public var sttEnabled: Bool
|
||||||
@@ -217,7 +236,9 @@ public struct VoiceSettings: Sendable, Equatable {
|
|||||||
sttLocalModel: String,
|
sttLocalModel: String,
|
||||||
sttLocalLanguage: String,
|
sttLocalLanguage: String,
|
||||||
sttOpenAIModel: String,
|
sttOpenAIModel: String,
|
||||||
sttMistralModel: String
|
sttMistralModel: String,
|
||||||
|
ttsXAIVoiceID: String = "",
|
||||||
|
ttsXAIModel: String = ""
|
||||||
) {
|
) {
|
||||||
self.recordKey = recordKey
|
self.recordKey = recordKey
|
||||||
self.maxRecordingSeconds = maxRecordingSeconds
|
self.maxRecordingSeconds = maxRecordingSeconds
|
||||||
@@ -230,6 +251,8 @@ public struct VoiceSettings: Sendable, Equatable {
|
|||||||
self.ttsOpenAIVoice = ttsOpenAIVoice
|
self.ttsOpenAIVoice = ttsOpenAIVoice
|
||||||
self.ttsNeuTTSModel = ttsNeuTTSModel
|
self.ttsNeuTTSModel = ttsNeuTTSModel
|
||||||
self.ttsNeuTTSDevice = ttsNeuTTSDevice
|
self.ttsNeuTTSDevice = ttsNeuTTSDevice
|
||||||
|
self.ttsXAIVoiceID = ttsXAIVoiceID
|
||||||
|
self.ttsXAIModel = ttsXAIModel
|
||||||
self.sttEnabled = sttEnabled
|
self.sttEnabled = sttEnabled
|
||||||
self.sttProvider = sttProvider
|
self.sttProvider = sttProvider
|
||||||
self.sttLocalModel = sttLocalModel
|
self.sttLocalModel = sttLocalModel
|
||||||
@@ -254,7 +277,9 @@ public struct VoiceSettings: Sendable, Equatable {
|
|||||||
sttLocalModel: "base",
|
sttLocalModel: "base",
|
||||||
sttLocalLanguage: "",
|
sttLocalLanguage: "",
|
||||||
sttOpenAIModel: "whisper-1",
|
sttOpenAIModel: "whisper-1",
|
||||||
sttMistralModel: "voxtral-mini-latest"
|
sttMistralModel: "voxtral-mini-latest",
|
||||||
|
ttsXAIVoiceID: "",
|
||||||
|
ttsXAIModel: ""
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -229,6 +229,12 @@ public final class RichChatViewModel {
|
|||||||
public private(set) var acpOutputTokens = 0
|
public private(set) var acpOutputTokens = 0
|
||||||
public private(set) var acpThoughtTokens = 0
|
public private(set) var acpThoughtTokens = 0
|
||||||
public private(set) var acpCachedReadTokens = 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`.
|
/// Slash commands advertised by the ACP server via `available_commands_update`.
|
||||||
public private(set) var acpCommands: [HermesSlashCommand] = []
|
public private(set) var acpCommands: [HermesSlashCommand] = []
|
||||||
@@ -468,6 +474,7 @@ public final class RichChatViewModel {
|
|||||||
acpErrorHint = nil
|
acpErrorHint = nil
|
||||||
acpErrorDetails = nil
|
acpErrorDetails = nil
|
||||||
acpCachedReadTokens = 0
|
acpCachedReadTokens = 0
|
||||||
|
acpCompressionCount = 0
|
||||||
acpCommands = []
|
acpCommands = []
|
||||||
projectScopedCommands = []
|
projectScopedCommands = []
|
||||||
currentTurnStart = nil
|
currentTurnStart = nil
|
||||||
@@ -811,6 +818,13 @@ public final class RichChatViewModel {
|
|||||||
acpOutputTokens += response.outputTokens
|
acpOutputTokens += response.outputTokens
|
||||||
acpThoughtTokens += response.thoughtTokens
|
acpThoughtTokens += response.thoughtTokens
|
||||||
acpCachedReadTokens += response.cachedReadTokens
|
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
|
isAgentWorking = false
|
||||||
buildMessageGroups()
|
buildMessageGroups()
|
||||||
// Final position after the prompt settles. Catches fast responses
|
// Final position after the prompt settles. Catches fast responses
|
||||||
|
|||||||
@@ -162,6 +162,47 @@ import Foundation
|
|||||||
// start → false.
|
// start → false.
|
||||||
#expect(vm.supportsCompress == false)
|
#expect(vm.supportsCompress == false)
|
||||||
#expect(vm.hasBroaderCommandMenu == 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() {
|
@Test @MainActor func messageGroupDerivedProperties() {
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -242,6 +242,15 @@ import Foundation
|
|||||||
thoughtTokens: 20, cachedReadTokens: 10
|
thoughtTokens: 20, cachedReadTokens: 10
|
||||||
)
|
)
|
||||||
#expect(prompt.stopReason == "end_turn")
|
#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() {
|
@Test func projectDashboardInitChain() {
|
||||||
|
|||||||
@@ -84,7 +84,11 @@ struct HermesFileService: Sendable {
|
|||||||
inlineDiffs: bool("display.inline_diffs", default: true),
|
inlineDiffs: bool("display.inline_diffs", default: true),
|
||||||
toolProgressCommand: bool("display.tool_progress_command", default: false),
|
toolProgressCommand: bool("display.tool_progress_command", default: false),
|
||||||
toolPreviewLength: int("display.tool_preview_length", default: 0),
|
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(
|
let terminal = TerminalSettings(
|
||||||
@@ -131,7 +135,12 @@ struct HermesFileService: Sendable {
|
|||||||
sttLocalModel: str("stt.local.model", default: "base"),
|
sttLocalModel: str("stt.local.model", default: "base"),
|
||||||
sttLocalLanguage: str("stt.local.language"),
|
sttLocalLanguage: str("stt.local.language"),
|
||||||
sttOpenAIModel: str("stt.openai.model", default: "whisper-1"),
|
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 {
|
func aux(_ name: String) -> AuxiliaryModel {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ struct ChatTranscriptPane: View {
|
|||||||
@Bindable var chatViewModel: ChatViewModel
|
@Bindable var chatViewModel: ChatViewModel
|
||||||
var onSend: (String, [ChatImageAttachment]) -> Void
|
var onSend: (String, [ChatImageAttachment]) -> Void
|
||||||
var isEnabled: Bool
|
var isEnabled: Bool
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
VStack(spacing: 0) {
|
VStack(spacing: 0) {
|
||||||
@@ -20,8 +21,10 @@ struct ChatTranscriptPane: View {
|
|||||||
acpInputTokens: richChat.acpInputTokens,
|
acpInputTokens: richChat.acpInputTokens,
|
||||||
acpOutputTokens: richChat.acpOutputTokens,
|
acpOutputTokens: richChat.acpOutputTokens,
|
||||||
acpThoughtTokens: richChat.acpThoughtTokens,
|
acpThoughtTokens: richChat.acpThoughtTokens,
|
||||||
|
acpCompressionCount: richChat.acpCompressionCount,
|
||||||
projectName: chatViewModel.currentProjectName,
|
projectName: chatViewModel.currentProjectName,
|
||||||
gitBranch: chatViewModel.currentGitBranch
|
gitBranch: chatViewModel.currentGitBranch,
|
||||||
|
capabilities: capabilitiesStore?.capabilities ?? .empty
|
||||||
)
|
)
|
||||||
Divider()
|
Divider()
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,11 @@ struct SessionInfoBar: View {
|
|||||||
var acpInputTokens: Int = 0
|
var acpInputTokens: Int = 0
|
||||||
var acpOutputTokens: Int = 0
|
var acpOutputTokens: Int = 0
|
||||||
var acpThoughtTokens: 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
|
/// Name of the Scarf project this session is attributed to, when
|
||||||
/// applicable. Nil for plain global chats. Drives the folder-chip
|
/// applicable. Nil for plain global chats. Drives the folder-chip
|
||||||
/// indicator rendered before the session title. Resolved by
|
/// 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
|
/// name. Nil for non-project chats and for projects that aren't
|
||||||
/// git repos.
|
/// git repos.
|
||||||
var gitBranch: String? = nil
|
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
|
/// Active Hermes profile name (issue #50). Resolved on each body
|
||||||
/// re-evaluation; the resolver caches for 5s so this is cheap.
|
/// re-evaluation; the resolver caches for 5s so this is cheap.
|
||||||
@@ -96,6 +106,21 @@ struct SessionInfoBar: View {
|
|||||||
Label("\(formatTokens(reasonToks)) reasoning", systemImage: "brain")
|
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 {
|
if let cost = session.displayCostUSD {
|
||||||
let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4)))
|
let formattedCost = cost.formatted(.currency(code: "USD").precision(.fractionLength(4)))
|
||||||
Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle")
|
Label(session.costIsActual ? formattedCost : "\(formattedCost) est.", systemImage: "dollarsign.circle")
|
||||||
|
|||||||
@@ -87,7 +87,16 @@ private struct SlashCommandRow: View {
|
|||||||
.fontWeight(.semibold)
|
.fontWeight(.semibold)
|
||||||
.foregroundStyle(isSelected ? ScarfColor.accentActive : ScarfColor.foregroundPrimary)
|
.foregroundStyle(isSelected ? ScarfColor.accentActive : ScarfColor.foregroundPrimary)
|
||||||
if let hint = command.argumentHint {
|
if let hint = command.argumentHint {
|
||||||
Text("<\(hint)>")
|
// v0.13: Hermes may emit hints already wrapped in
|
||||||
|
// brackets (e.g. `[name]` for the optional `/new
|
||||||
|
// <name>` argument exposed by `hasNewWithSessionName`).
|
||||||
|
// Avoid double-wrapping — bracketed hints pass through
|
||||||
|
// verbatim while older `guidance`-style hints (no
|
||||||
|
// brackets) still render as `<guidance>`.
|
||||||
|
let display = hint.hasPrefix("<") || hint.hasPrefix("[")
|
||||||
|
? hint
|
||||||
|
: "<\(hint)>"
|
||||||
|
Text(display)
|
||||||
.font(ScarfFont.monoSmall)
|
.font(ScarfFont.monoSmall)
|
||||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,8 +29,31 @@ final class SettingsViewModel {
|
|||||||
// that no-ops on older hosts is low compared to gating overhead.
|
// that no-ops on older hosts is low compared to gating overhead.
|
||||||
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh", "vercel"]
|
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh", "vercel"]
|
||||||
var browserBackends = ["browseruse", "firecrawl", "local"]
|
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"]
|
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 memoryProviders = ["", "honcho", "openviking", "mem0", "hindsight", "holographic", "retaindb", "byterover", "supermemory"]
|
||||||
var saveMessage: String?
|
var saveMessage: String?
|
||||||
var isLoading = false
|
var isLoading = false
|
||||||
@@ -104,6 +127,10 @@ final class SettingsViewModel {
|
|||||||
func setToolProgressCommand(_ value: Bool) { setSetting("display.tool_progress_command", value: value ? "true" : "false") }
|
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 setToolPreviewLength(_ value: Int) { setSetting("display.tool_preview_length", value: String(value)) }
|
||||||
func setBusyInputMode(_ value: String) { setSetting("display.busy_input_mode", value: 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
|
// MARK: - Agent
|
||||||
|
|
||||||
@@ -158,6 +185,10 @@ final class SettingsViewModel {
|
|||||||
func setTTSOpenAIVoice(_ value: String) { setSetting("tts.openai.voice", value: value) }
|
func setTTSOpenAIVoice(_ value: String) { setSetting("tts.openai.voice", value: value) }
|
||||||
func setTTSNeuTTSModel(_ value: String) { setSetting("tts.neutts.model", value: value) }
|
func setTTSNeuTTSModel(_ value: String) { setSetting("tts.neutts.model", value: value) }
|
||||||
func setTTSNeuTTSDevice(_ value: String) { setSetting("tts.neutts.device", 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 setSTTEnabled(_ value: Bool) { setSetting("stt.enabled", value: value ? "true" : "false") }
|
||||||
func setSTTProvider(_ value: String) { setSetting("stt.provider", value: value) }
|
func setSTTProvider(_ value: String) { setSetting("stt.provider", value: value) }
|
||||||
func setSTTLocalModel(_ value: String) { setSetting("stt.local.model", value: value) }
|
func setSTTLocalModel(_ value: String) { setSetting("stt.local.model", value: value) }
|
||||||
|
|||||||
@@ -152,8 +152,23 @@ struct PickerRow: View {
|
|||||||
let label: String
|
let label: String
|
||||||
let selection: String
|
let selection: String
|
||||||
let options: [String]
|
let options: [String]
|
||||||
|
let optionLabel: ((String) -> String)?
|
||||||
let onChange: (String) -> Void
|
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 {
|
var body: some View {
|
||||||
HStack {
|
HStack {
|
||||||
SettingsRowLabel(label: label)
|
SettingsRowLabel(label: label)
|
||||||
@@ -162,7 +177,7 @@ struct PickerRow: View {
|
|||||||
set: { onChange($0) }
|
set: { onChange($0) }
|
||||||
)) {
|
)) {
|
||||||
ForEach(options, id: \.self) { option in
|
ForEach(options, id: \.self) { option in
|
||||||
Text(option.isEmpty ? "(none)" : option).tag(option)
|
Text(displayLabel(for: option)).tag(option)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.frame(maxWidth: 250)
|
.frame(maxWidth: 250)
|
||||||
@@ -170,6 +185,13 @@ struct PickerRow: View {
|
|||||||
}
|
}
|
||||||
.settingsRowChrome()
|
.settingsRowChrome()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func displayLabel(for option: String) -> String {
|
||||||
|
if let mapper = optionLabel {
|
||||||
|
return mapper(option)
|
||||||
|
}
|
||||||
|
return option.isEmpty ? "(none)" : option
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct ToggleRow: View {
|
struct ToggleRow: View {
|
||||||
|
|||||||
@@ -131,6 +131,8 @@ struct AdvancedTab: View {
|
|||||||
isOn: viewModel.config.redactionEnabled
|
isOn: viewModel.config.redactionEnabled
|
||||||
) { viewModel.setSetting("redaction.enabled", value: $0 ? "true" : "false") }
|
) { viewModel.setSetting("redaction.enabled", value: $0 ? "true" : "false") }
|
||||||
|
|
||||||
|
redactionDefaultsHint
|
||||||
|
|
||||||
ToggleRow(
|
ToggleRow(
|
||||||
label: "Runtime metadata footer",
|
label: "Runtime metadata footer",
|
||||||
isOn: viewModel.config.runtimeMetadataFooter
|
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 {
|
private var backupSection: some View {
|
||||||
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
|
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
|
||||||
HStack {
|
HStack {
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import ScarfCore
|
|||||||
struct GeneralTab: View {
|
struct GeneralTab: View {
|
||||||
@Bindable var viewModel: SettingsViewModel
|
@Bindable var viewModel: SettingsViewModel
|
||||||
@Environment(AppCoordinator.self) private var coordinator
|
@Environment(AppCoordinator.self) private var coordinator
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SettingsSection(title: "Model", icon: "cpu") {
|
SettingsSection(title: "Model", icon: "cpu") {
|
||||||
@@ -39,6 +40,20 @@ struct GeneralTab: View {
|
|||||||
|
|
||||||
SettingsSection(title: "Locale", icon: "globe.americas") {
|
SettingsSection(title: "Locale", icon: "globe.americas") {
|
||||||
EditableTextField(label: "Timezone (IANA)", value: viewModel.config.timezone) { viewModel.setTimezone($0) }
|
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()
|
UpdatesSection()
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import ScarfCore
|
import ScarfCore
|
||||||
|
import ScarfDesign
|
||||||
|
|
||||||
/// Voice tab — push-to-talk + TTS + STT provider settings.
|
/// Voice tab — push-to-talk + TTS + STT provider settings.
|
||||||
struct VoiceTab: View {
|
struct VoiceTab: View {
|
||||||
@Bindable var viewModel: SettingsViewModel
|
@Bindable var viewModel: SettingsViewModel
|
||||||
|
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
SettingsSection(title: "Push-to-Talk", icon: "mic") {
|
SettingsSection(title: "Push-to-Talk", icon: "mic") {
|
||||||
@@ -28,6 +30,16 @@ struct VoiceTab: View {
|
|||||||
case "neutts":
|
case "neutts":
|
||||||
EditableTextField(label: "Model", value: viewModel.config.voice.ttsNeuTTSModel) { viewModel.setTTSNeuTTSModel($0) }
|
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) }
|
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:
|
default:
|
||||||
EmptyView()
|
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