mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f78856e6e | |||
| 5877bf6519 | |||
| f19f19cd56 |
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// Optimistic local mirror of the agent's currently-locked goal (set via
|
||||
/// the `/goal <text>` slash command, Hermes v0.13+). Scarf records this
|
||||
/// the moment the user sends `/goal …` so the chat header pill appears
|
||||
/// synchronously, without waiting for a server round-trip. There is no
|
||||
/// authoritative read-back path in v2.8.0 — see WS-2 plan Q1.
|
||||
///
|
||||
/// Plain value type, no mutation API. Drives the goal pill in
|
||||
/// `SessionInfoBar` and the inspector contextual menu.
|
||||
public struct HermesActiveGoal: Sendable, Equatable, Identifiable {
|
||||
/// The user's verbatim goal text (post-trim).
|
||||
public let text: String
|
||||
/// When Scarf observed the `/goal` send. Local clock — not the
|
||||
/// server's authoritative timestamp.
|
||||
public let setAt: Date
|
||||
|
||||
public var id: String {
|
||||
text + "@" + ISO8601DateFormatter().string(from: setAt)
|
||||
}
|
||||
|
||||
public init(text: String, setAt: Date) {
|
||||
self.text = text
|
||||
self.setAt = setAt
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@ public struct DisplaySettings: Sendable, Equatable {
|
||||
public var toolProgressCommand: Bool
|
||||
public var toolPreviewLength: Int
|
||||
public var busyInputMode: String // e.g. "interrupt"
|
||||
/// Static-message translation language. v0.13+. Empty string means
|
||||
/// "follow Hermes default" — the picker collapses both empty-string
|
||||
/// and `"en"` to "English" in display, but only writes a value when
|
||||
/// the user explicitly picks one. Persisted via
|
||||
/// `hermes config set display.language <code>`. Supported values per
|
||||
/// v0.13 release notes: `en`, `zh`, `ja`, `de`, `es`, `fr`, `uk`, `tr`.
|
||||
public var language: String
|
||||
|
||||
|
||||
public init(
|
||||
@@ -46,7 +53,8 @@ public struct DisplaySettings: Sendable, Equatable {
|
||||
inlineDiffs: Bool,
|
||||
toolProgressCommand: Bool,
|
||||
toolPreviewLength: Int,
|
||||
busyInputMode: String
|
||||
busyInputMode: String,
|
||||
language: String = ""
|
||||
) {
|
||||
self.skin = skin
|
||||
self.compact = compact
|
||||
@@ -56,6 +64,7 @@ public struct DisplaySettings: Sendable, Equatable {
|
||||
self.toolProgressCommand = toolProgressCommand
|
||||
self.toolPreviewLength = toolPreviewLength
|
||||
self.busyInputMode = busyInputMode
|
||||
self.language = language
|
||||
}
|
||||
public nonisolated static let empty = DisplaySettings(
|
||||
skin: "default",
|
||||
@@ -65,7 +74,8 @@ public struct DisplaySettings: Sendable, Equatable {
|
||||
inlineDiffs: true,
|
||||
toolProgressCommand: false,
|
||||
toolPreviewLength: 0,
|
||||
busyInputMode: "interrupt"
|
||||
busyInputMode: "interrupt",
|
||||
language: ""
|
||||
)
|
||||
}
|
||||
|
||||
@@ -190,6 +200,15 @@ public struct VoiceSettings: Sendable, Equatable {
|
||||
public var ttsOpenAIVoice: String
|
||||
public var ttsNeuTTSModel: String
|
||||
public var ttsNeuTTSDevice: String
|
||||
/// xAI TTS voice identifier. v0.13+ — xAI shipped TTS earlier but the
|
||||
/// custom-voice / cloning surface is the v0.13 add-on.
|
||||
// TODO(WS-8-Q2): Confirm key name vs `tts.xai.voice` /
|
||||
// `tts.xai.voice_id` / a top-level `tts.xai_voice` once a v0.13
|
||||
// host is on hand. The setter / YAML reader follow whatever this
|
||||
// field name implies.
|
||||
public var ttsXAIVoiceID: String
|
||||
/// xAI TTS model identifier. v0.13+. Mirrors the elevenlabs shape.
|
||||
public var ttsXAIModel: String
|
||||
|
||||
// STT
|
||||
public var sttEnabled: Bool
|
||||
@@ -217,7 +236,9 @@ public struct VoiceSettings: Sendable, Equatable {
|
||||
sttLocalModel: String,
|
||||
sttLocalLanguage: String,
|
||||
sttOpenAIModel: String,
|
||||
sttMistralModel: String
|
||||
sttMistralModel: String,
|
||||
ttsXAIVoiceID: String = "",
|
||||
ttsXAIModel: String = ""
|
||||
) {
|
||||
self.recordKey = recordKey
|
||||
self.maxRecordingSeconds = maxRecordingSeconds
|
||||
@@ -230,6 +251,8 @@ public struct VoiceSettings: Sendable, Equatable {
|
||||
self.ttsOpenAIVoice = ttsOpenAIVoice
|
||||
self.ttsNeuTTSModel = ttsNeuTTSModel
|
||||
self.ttsNeuTTSDevice = ttsNeuTTSDevice
|
||||
self.ttsXAIVoiceID = ttsXAIVoiceID
|
||||
self.ttsXAIModel = ttsXAIModel
|
||||
self.sttEnabled = sttEnabled
|
||||
self.sttProvider = sttProvider
|
||||
self.sttLocalModel = sttLocalModel
|
||||
@@ -254,7 +277,9 @@ public struct VoiceSettings: Sendable, Equatable {
|
||||
sttLocalModel: "base",
|
||||
sttLocalLanguage: "",
|
||||
sttOpenAIModel: "whisper-1",
|
||||
sttMistralModel: "voxtral-mini-latest"
|
||||
sttMistralModel: "voxtral-mini-latest",
|
||||
ttsXAIVoiceID: "",
|
||||
ttsXAIModel: ""
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
/// One queued prompt the user has staged via `/queue <text>` (Hermes
|
||||
/// v0.13+ ACP `/queue` slash command). Hermes is the authoritative owner
|
||||
/// of the actual queue server-side — Scarf maintains this mirror so the
|
||||
/// chat header chip + popover can show "what's pending" without an
|
||||
/// extra round-trip. The mirror drains best-effort when a turn
|
||||
/// completes (`RichChatViewModel.popQueuedPrompt`).
|
||||
///
|
||||
/// `id` is a Scarf-side UUID minted at queue-time — Hermes' wire
|
||||
/// protocol does not expose a per-queue-entry id, so we never round-trip
|
||||
/// an entry-level identifier. See WS-2 plan Q5.
|
||||
public struct HermesQueuedPrompt: Sendable, Equatable, Identifiable {
|
||||
public let id: UUID
|
||||
public let text: String
|
||||
public let queuedAt: Date
|
||||
|
||||
public init(id: UUID = UUID(), text: String, queuedAt: Date = Date()) {
|
||||
self.id = id
|
||||
self.text = text
|
||||
self.queuedAt = queuedAt
|
||||
}
|
||||
}
|
||||
@@ -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 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] = []
|
||||
@@ -248,73 +254,15 @@ public final class RichChatViewModel {
|
||||
/// Hermes v2026.4.23+ but listed here unconditionally so older
|
||||
/// hosts that don't advertise it still surface the trigger; the
|
||||
/// agent will respond appropriately or no-op gracefully.
|
||||
///
|
||||
/// v2.8 / Hermes v0.13 adds `/goal` (lock the agent on a target
|
||||
/// across turns) and `/queue` (queue a prompt for after the current
|
||||
/// turn). Both ride the same `.acpNonInterruptive` source — Hermes
|
||||
/// parses them server-side, the wire shape is plain
|
||||
/// `session/prompt`, and the chat UI keeps the "Agent working…"
|
||||
/// indicator off when they're sent. They're listed unconditionally
|
||||
/// here; capability filtering happens in `availableCommands` so
|
||||
/// pre-v0.13 hosts don't see `/goal` or `/queue` in the slash menu.
|
||||
// TODO(WS-2-Q7): verify against a real v0.13 ACP host that `/goal`
|
||||
// is in fact non-interruptive on the wire. If Hermes treats it as a
|
||||
// regular prompt that flips "Agent working…", drop it from this
|
||||
// list and route it through the standard send path (the pill
|
||||
// bookkeeping in `recordActiveGoal` is independent of the
|
||||
// interruptive classification).
|
||||
public static let nonInterruptiveCommands: [HermesSlashCommand] = [
|
||||
HermesSlashCommand(
|
||||
name: "steer",
|
||||
description: "Nudge the agent mid-run (applies after the next tool call)",
|
||||
argumentHint: "<guidance>",
|
||||
source: .acpNonInterruptive
|
||||
),
|
||||
HermesSlashCommand(
|
||||
name: "goal",
|
||||
description: "Lock the agent on a goal that persists across turns",
|
||||
argumentHint: "<text>",
|
||||
source: .acpNonInterruptive
|
||||
),
|
||||
HermesSlashCommand(
|
||||
name: "queue",
|
||||
description: "Queue a prompt to run after the current turn",
|
||||
argumentHint: "<text>",
|
||||
source: .acpNonInterruptive
|
||||
)
|
||||
]
|
||||
|
||||
/// Capability snapshot the chat surface uses to filter
|
||||
/// `availableCommands`. Set by the chat controller (Mac
|
||||
/// `ChatViewModel`, iOS `ChatController`) at session-start time and
|
||||
/// kept fresh via the `HermesCapabilitiesStore` env binding. Default
|
||||
/// `.empty` means "no v0.13 surfaces" — pre-v0.13 hosts and harness
|
||||
/// scenarios (Previews, smoke tests) never expose `/goal` or
|
||||
/// `/queue` until the controller publishes a real capabilities
|
||||
/// value. `@ObservationIgnored` so capability refreshes don't trash
|
||||
/// the streaming-message render budget; controllers call
|
||||
/// `publishCapabilities(_:)` once per refresh tick.
|
||||
@ObservationIgnored
|
||||
public var capabilitiesGate: HermesCapabilities = .empty
|
||||
|
||||
/// Optimistic local mirror of the agent's currently-locked goal.
|
||||
/// Set by `recordActiveGoal(text:)` the moment the user sends
|
||||
/// `/goal …`; cleared on `/goal --clear` or `reset()`. Pre-v0.13
|
||||
/// hosts can't reach this code path (the slash menu hides `/goal`),
|
||||
/// but a typed-out `/goal foo` against an older host would still
|
||||
/// land here briefly until Hermes' "unknown command" reply lands —
|
||||
/// see WS-2 plan "Inconsistency caveat".
|
||||
public private(set) var activeGoal: HermesActiveGoal?
|
||||
|
||||
/// Optimistic mirror of prompts the user has queued via `/queue …`
|
||||
/// while a turn is in flight. Hermes is the authoritative owner
|
||||
/// server-side; this list drives the chat-header chip + popover and
|
||||
/// drains FIFO via `popQueuedPrompt()` when a turn completes.
|
||||
/// Best-effort: if Hermes' server-side queue gets out of sync
|
||||
/// (deferred prompt aborted, dropped on disconnect) the user sees a
|
||||
/// stale chip until their next interaction.
|
||||
public private(set) var queuedPrompts: [HermesQueuedPrompt] = []
|
||||
|
||||
/// Transient hint shown above the composer, e.g. "Guidance queued —
|
||||
/// applies after the next tool call." for `/steer`. The chat view
|
||||
/// auto-clears it after a short delay (handled in the view); the
|
||||
@@ -376,94 +324,12 @@ public final class RichChatViewModel {
|
||||
!acpNames.contains($0.name) && !projectNames.contains($0.name)
|
||||
}
|
||||
let occupied = acpNames.union(projectNames).union(Set(quicks.map(\.name)))
|
||||
// Capability gate: `/goal` and `/queue` are v0.13+ surfaces;
|
||||
// hide them when the connected host is older. `/steer` is
|
||||
// surfaced unconditionally — it works on v0.11+ during an
|
||||
// active turn; idle-session greying for pre-v0.13 hosts is
|
||||
// the input bar's concern (it reads `hasACPSteerOnIdle`).
|
||||
let supported: [HermesSlashCommand] = Self.nonInterruptiveCommands.filter { cmd in
|
||||
switch cmd.name {
|
||||
case "goal": return capabilitiesGate.hasGoals
|
||||
case "queue": return capabilitiesGate.hasACPQueue
|
||||
case "steer": return true
|
||||
default: return true
|
||||
}
|
||||
let nonInterruptive = Self.nonInterruptiveCommands.filter {
|
||||
!occupied.contains($0.name)
|
||||
}
|
||||
let nonInterruptive = supported.filter { !occupied.contains($0.name) }
|
||||
return acpCommands + projectAsHermes + quicks + nonInterruptive
|
||||
}
|
||||
|
||||
/// Publish a fresh capabilities snapshot from the controller.
|
||||
/// Called whenever `HermesCapabilitiesStore.capabilities` changes
|
||||
/// (initial detection, post-refresh, server switch). The chat input
|
||||
/// bar's slash menu re-reads `availableCommands` lazily, so this is
|
||||
/// just a stored-value swap — no observable churn.
|
||||
public func publishCapabilities(_ caps: HermesCapabilities) {
|
||||
capabilitiesGate = caps
|
||||
}
|
||||
|
||||
/// Optimistic write triggered when the user sends `/goal <text>`.
|
||||
/// Pass `nil` (or empty) to clear (the `/goal --clear` path). The
|
||||
/// pill renders synchronously off this state; there is no
|
||||
/// authoritative server read-back in v2.8.0 — see WS-2 plan Q1.
|
||||
// TODO(WS-2-Q1): hook a Hermes-supplied goal-state read-back path
|
||||
// here once we know whether v0.13 exposes goal state via an ACP
|
||||
// session-startup notification, a session-sidecar JSON field, or a
|
||||
// `/goal --status` reply. Until then `activeGoal` is purely
|
||||
// user-set and does not survive a session resume.
|
||||
public func recordActiveGoal(text: String?) {
|
||||
if let text, !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||
activeGoal = HermesActiveGoal(
|
||||
text: text.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
setAt: Date()
|
||||
)
|
||||
} else {
|
||||
activeGoal = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Append an optimistically-queued prompt to the local mirror
|
||||
/// (driven by `/queue <text>`). No-op for empty / whitespace input.
|
||||
public func recordQueuedPrompt(text: String) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
queuedPrompts.append(HermesQueuedPrompt(text: trimmed))
|
||||
}
|
||||
|
||||
/// Drain the next queued prompt off the local mirror, FIFO. Called
|
||||
/// from `handlePromptComplete` once a turn settles — Hermes runs
|
||||
/// the actual queued prompt server-side; popping here keeps the
|
||||
/// header chip count honest. Returns the popped prompt for any
|
||||
/// caller that wants to log it; the chat UI ignores the return.
|
||||
@discardableResult
|
||||
public func popQueuedPrompt() -> HermesQueuedPrompt? {
|
||||
queuedPrompts.isEmpty ? nil : queuedPrompts.removeFirst()
|
||||
}
|
||||
|
||||
/// Parse the argument slug from a `/goal …` invocation. Pure
|
||||
/// function — exposed for unit tests. The chat dispatch reads this
|
||||
/// to decide whether to set, clear, or no-op the optimistic pill.
|
||||
public enum GoalCommandArgument: Equatable {
|
||||
case set(String)
|
||||
case clear
|
||||
/// User typed `/goal` with no argument — Hermes will reply
|
||||
/// with usage; Scarf shows a neutral hint and doesn't touch
|
||||
/// the pill state.
|
||||
case empty
|
||||
}
|
||||
|
||||
public static func parseGoalArgument(_ raw: String) -> GoalCommandArgument {
|
||||
let trimmed = raw.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty { return .empty }
|
||||
// Accept `--clear`, `clear`, and case-insensitive variants so
|
||||
// typos don't accidentally lock the goal text to literal
|
||||
// "Clear". `--clear` is the canonical form (matches Hermes
|
||||
// CLI flag style).
|
||||
let lowered = trimmed.lowercased()
|
||||
if lowered == "--clear" || lowered == "clear" { return .clear }
|
||||
return .set(trimmed)
|
||||
}
|
||||
|
||||
/// True when `text` is a non-interruptive command that should NOT
|
||||
/// flip `isAgentWorking` to true on send. Used by the Mac/iOS chat
|
||||
/// view models to skip the "agent working" overlay change for
|
||||
@@ -608,20 +474,13 @@ public final class RichChatViewModel {
|
||||
acpErrorHint = nil
|
||||
acpErrorDetails = nil
|
||||
acpCachedReadTokens = 0
|
||||
acpCompressionCount = 0
|
||||
acpCommands = []
|
||||
projectScopedCommands = []
|
||||
currentTurnStart = nil
|
||||
turnDurations = [:]
|
||||
transientHint = nil
|
||||
pendingPermission = nil
|
||||
// v2.8 / Hermes v0.13 — drop optimistic v0.13 surfaces on
|
||||
// session reset so a fresh chat (or a resume into a different
|
||||
// session) doesn't paint stale goal / queue state from the
|
||||
// previous one. The capabilities gate stays on whatever the
|
||||
// controller most recently published; it's a host-level value
|
||||
// that doesn't change with session boundaries.
|
||||
activeGoal = nil
|
||||
queuedPrompts = []
|
||||
loadQuickCommands()
|
||||
}
|
||||
|
||||
@@ -959,23 +818,14 @@ 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
|
||||
// local mirror FIFO so the header chip count matches what the
|
||||
// user staged. Best-effort: if Hermes' authoritative queue
|
||||
// diverged (deferred prompt aborted, dropped on disconnect),
|
||||
// the chip is one tick stale until the user's next interaction.
|
||||
if !queuedPrompts.isEmpty {
|
||||
popQueuedPrompt()
|
||||
}
|
||||
// TODO(v2.8.1): when this completes after an auto-resumed
|
||||
// checkpoint (Hermes v0.13's "Auto-resume interrupted sessions
|
||||
// after gateway restart"), surface a one-shot "Auto-resumed
|
||||
// from checkpoint" indicator. Wire-shape unknown until a v0.13
|
||||
// dogfooding pass confirms whether the resume lands as a
|
||||
// visible ACP event or is purely server-side. Deferred from
|
||||
// v2.8.0 per WS-2 plan Q3.
|
||||
buildMessageGroups()
|
||||
// Final position after the prompt settles. Catches fast responses
|
||||
// (slash commands, short replies) where `.defaultScrollAnchor(.bottom)`
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
}
|
||||
@@ -241,150 +241,6 @@ import Foundation
|
||||
#expect(a == b)
|
||||
}
|
||||
|
||||
// MARK: - v0.13 non-interruptive commands (WS-2 / Persistent Goals + /queue)
|
||||
|
||||
@Test func nonInterruptiveListIncludesGoalAndQueue() {
|
||||
let names = RichChatViewModel.nonInterruptiveCommands.map(\.name)
|
||||
#expect(names.contains("steer"))
|
||||
#expect(names.contains("goal"))
|
||||
#expect(names.contains("queue"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func availableCommandsHidesGoalWhenCapabilityOff() {
|
||||
let vm = RichChatViewModel(context: .local)
|
||||
vm.publishCapabilities(.empty)
|
||||
let names = vm.availableCommands.map(\.name)
|
||||
#expect(!names.contains("goal"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func availableCommandsHidesQueueWhenCapabilityOff() {
|
||||
let vm = RichChatViewModel(context: .local)
|
||||
vm.publishCapabilities(.empty)
|
||||
let names = vm.availableCommands.map(\.name)
|
||||
#expect(!names.contains("queue"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func availableCommandsExposesAllThreeOnV013() {
|
||||
let vm = RichChatViewModel(context: .local)
|
||||
let caps = HermesCapabilities.parseLine("Hermes Agent v0.13.0 (2026.5.7)")
|
||||
vm.publishCapabilities(caps)
|
||||
let names = vm.availableCommands.map(\.name)
|
||||
#expect(names.contains("steer"))
|
||||
#expect(names.contains("goal"))
|
||||
#expect(names.contains("queue"))
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func availableCommandsExposesSteerButHidesV013OnV012() {
|
||||
let vm = RichChatViewModel(context: .local)
|
||||
let caps = HermesCapabilities.parseLine("Hermes Agent v0.12.0 (2026.4.30)")
|
||||
vm.publishCapabilities(caps)
|
||||
let names = vm.availableCommands.map(\.name)
|
||||
#expect(names.contains("steer"))
|
||||
#expect(!names.contains("goal"))
|
||||
#expect(!names.contains("queue"))
|
||||
}
|
||||
|
||||
@Test func parseGoalArgumentRecognizesClearVariants() {
|
||||
#expect(RichChatViewModel.parseGoalArgument("--clear") == .clear)
|
||||
#expect(RichChatViewModel.parseGoalArgument("clear") == .clear)
|
||||
#expect(RichChatViewModel.parseGoalArgument("Clear") == .clear)
|
||||
#expect(RichChatViewModel.parseGoalArgument(" --clear ") == .clear)
|
||||
}
|
||||
|
||||
@Test func parseGoalArgumentReturnsSetForArbitraryText() {
|
||||
#expect(
|
||||
RichChatViewModel.parseGoalArgument("finish v2.8 on time")
|
||||
== .set("finish v2.8 on time")
|
||||
)
|
||||
// Whitespace around set text is trimmed.
|
||||
#expect(
|
||||
RichChatViewModel.parseGoalArgument(" ship it ")
|
||||
== .set("ship it")
|
||||
)
|
||||
}
|
||||
|
||||
@Test func parseGoalArgumentReturnsEmptyForBlank() {
|
||||
#expect(RichChatViewModel.parseGoalArgument("") == .empty)
|
||||
#expect(RichChatViewModel.parseGoalArgument(" ") == .empty)
|
||||
#expect(RichChatViewModel.parseGoalArgument("\n\t") == .empty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func recordActiveGoalSetsAndClears() {
|
||||
let vm = RichChatViewModel(context: .local)
|
||||
#expect(vm.activeGoal == nil)
|
||||
vm.recordActiveGoal(text: "ship v2.8")
|
||||
let goal = vm.activeGoal
|
||||
#expect(goal?.text == "ship v2.8")
|
||||
vm.recordActiveGoal(text: nil)
|
||||
#expect(vm.activeGoal == nil)
|
||||
// Empty / whitespace also clears.
|
||||
vm.recordActiveGoal(text: "x")
|
||||
vm.recordActiveGoal(text: " ")
|
||||
#expect(vm.activeGoal == nil)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func recordQueuedPromptAppendsAndPopsFIFO() {
|
||||
let vm = RichChatViewModel(context: .local)
|
||||
vm.recordQueuedPrompt(text: "first")
|
||||
vm.recordQueuedPrompt(text: "second")
|
||||
vm.recordQueuedPrompt(text: "third")
|
||||
#expect(vm.queuedPrompts.count == 3)
|
||||
let popped = vm.popQueuedPrompt()
|
||||
#expect(popped?.text == "first")
|
||||
#expect(vm.queuedPrompts.count == 2)
|
||||
let next = vm.popQueuedPrompt()
|
||||
#expect(next?.text == "second")
|
||||
#expect(vm.queuedPrompts.first?.text == "third")
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func recordQueuedPromptIgnoresBlank() {
|
||||
let vm = RichChatViewModel(context: .local)
|
||||
vm.recordQueuedPrompt(text: "")
|
||||
vm.recordQueuedPrompt(text: " ")
|
||||
#expect(vm.queuedPrompts.isEmpty)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func popQueuedPromptOnEmptyReturnsNil() {
|
||||
let vm = RichChatViewModel(context: .local)
|
||||
#expect(vm.popQueuedPrompt() == nil)
|
||||
}
|
||||
|
||||
@Test func isNonInterruptiveSlashRecognizesGoalAndQueue() {
|
||||
// Non-MainActor: the helper itself isn't MainActor-isolated;
|
||||
// construct a VM on MainActor and read through it on the test
|
||||
// actor to keep the assertion focused on classification.
|
||||
Task { @MainActor in
|
||||
let vm = RichChatViewModel(context: .local)
|
||||
#expect(vm.isNonInterruptiveSlash("/goal finish v2.8"))
|
||||
#expect(vm.isNonInterruptiveSlash("/queue summarize"))
|
||||
#expect(vm.isNonInterruptiveSlash("/queue"))
|
||||
#expect(vm.isNonInterruptiveSlash("/steer be careful"))
|
||||
#expect(!vm.isNonInterruptiveSlash("hello"))
|
||||
#expect(!vm.isNonInterruptiveSlash("/compress"))
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Test func resetClearsGoalAndQueue() {
|
||||
let vm = RichChatViewModel(context: .local)
|
||||
vm.recordActiveGoal(text: "x")
|
||||
vm.recordQueuedPrompt(text: "a")
|
||||
vm.recordQueuedPrompt(text: "b")
|
||||
#expect(vm.activeGoal != nil)
|
||||
#expect(vm.queuedPrompts.count == 2)
|
||||
vm.reset()
|
||||
#expect(vm.activeGoal == nil)
|
||||
#expect(vm.queuedPrompts.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
static func makeTempProject() throws -> String {
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -109,17 +109,6 @@ struct ChatView: View {
|
||||
}
|
||||
)
|
||||
}
|
||||
// Forward the env-injected capabilities snapshot into the
|
||||
// shared `RichChatViewModel` whenever it changes. Drives the
|
||||
// capability gate `RichChatViewModel.availableCommands` reads.
|
||||
// Mirrors the Mac `ChatView` plumbing — the iOS chat surface
|
||||
// doesn't render `/goal` / `/queue` UI yet (deferred to WS-9),
|
||||
// but the VM-side state has to stay aligned across platforms
|
||||
// so the Mac surface is correct after a cross-device session
|
||||
// resume.
|
||||
.task(id: capabilitiesStore?.capabilities.versionLine ?? "") {
|
||||
controller.vm.publishCapabilities(capabilitiesStore?.capabilities ?? .empty)
|
||||
}
|
||||
.task {
|
||||
// Dashboard row taps set `pendingResumeSessionID`, Project
|
||||
// Detail's "New Chat" sets `pendingProjectChat`. Both fire
|
||||
@@ -1318,48 +1307,18 @@ final class ChatController {
|
||||
// even when they didn't type any caption.
|
||||
vm.addUserMessage(text: "[image attached]")
|
||||
}
|
||||
// Non-interruptive slash commands: keep the chat working
|
||||
// indicator off and surface a transient toast confirming the
|
||||
// command was accepted. v2.5 added `/steer`; v2.8 / Hermes
|
||||
// v0.13 adds `/goal` (lock the agent on a target across
|
||||
// turns) and `/queue` (queue a prompt for after the current
|
||||
// turn). Each gets its own optimistic side-effect on the VM
|
||||
// so the (Mac-rendered) chat header pill / queue chip update
|
||||
// synchronously. iOS doesn't surface those affordances yet
|
||||
// (WS-9), but mirroring the dispatch keeps the shared VM
|
||||
// state aligned across platforms — otherwise an iOS user who
|
||||
// ran `/goal` then opened the same session on Mac would see
|
||||
// an empty pill until they typed `/goal` again.
|
||||
let parsedSlash = Self.parseSlashName(text)
|
||||
switch parsedSlash.name {
|
||||
case "goal":
|
||||
// TODO(WS-2-Q7): verify on a real v0.13 host.
|
||||
let arg = RichChatViewModel.parseGoalArgument(parsedSlash.args)
|
||||
switch arg {
|
||||
case .set(let goalText):
|
||||
vm.recordActiveGoal(text: goalText)
|
||||
vm.transientHint = "Goal locked: \(Self.truncatedToastGoal(goalText))"
|
||||
case .clear:
|
||||
vm.recordActiveGoal(text: nil)
|
||||
vm.transientHint = "Goal cleared."
|
||||
case .empty:
|
||||
vm.transientHint = "Sent /goal — see the agent reply for current goal."
|
||||
}
|
||||
scheduleTransientHintClear(snapshot: vm.transientHint)
|
||||
case "queue":
|
||||
// TODO(WS-2-Q5): verify the verbatim wire shape on a
|
||||
// real v0.13 ACP host.
|
||||
let queuedText = parsedSlash.args.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !queuedText.isEmpty {
|
||||
vm.recordQueuedPrompt(text: queuedText)
|
||||
}
|
||||
vm.transientHint = "Queued — runs after current turn."
|
||||
scheduleTransientHintClear(snapshot: vm.transientHint)
|
||||
case "steer" where vm.isNonInterruptiveSlash(text):
|
||||
// /steer is non-interruptive — the agent is still on its
|
||||
// current turn; the guidance applies after the next tool call.
|
||||
// Surface a transient toast confirming the guidance was
|
||||
// received. v2.5 / Hermes v2026.4.23+.
|
||||
if vm.isNonInterruptiveSlash(text) {
|
||||
vm.transientHint = "Guidance queued — applies after the next tool call."
|
||||
scheduleTransientHintClear(snapshot: vm.transientHint)
|
||||
default:
|
||||
break
|
||||
Task { @MainActor [weak vm] in
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if vm?.transientHint == "Guidance queued — applies after the next tool call." {
|
||||
vm?.transientHint = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
// Project-scoped slash commands expand client-side: the user
|
||||
// bubble shows the literal `/<name> args` they typed (above);
|
||||
@@ -1382,43 +1341,6 @@ final class ChatController {
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull `(name, argTail)` out of a `/<name> [args]` invocation.
|
||||
/// Mirror of `ChatViewModel.parseSlashName` on Mac. Returns
|
||||
/// `(nil, "")` for non-slash input.
|
||||
static func parseSlashName(_ text: String) -> (name: String?, args: String) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("/") else { return (nil, "") }
|
||||
let withoutSlash = trimmed.dropFirst()
|
||||
if let space = withoutSlash.firstIndex(of: " ") {
|
||||
return (
|
||||
name: String(withoutSlash[..<space]),
|
||||
args: String(withoutSlash[withoutSlash.index(after: space)...])
|
||||
)
|
||||
}
|
||||
return (name: String(withoutSlash), args: "")
|
||||
}
|
||||
|
||||
/// Cap goal text in transient toasts so a 1 KB user-typed goal
|
||||
/// doesn't blow out the hint pill. Mirror of
|
||||
/// `ChatViewModel.truncatedToastGoal`.
|
||||
static func truncatedToastGoal(_ text: String) -> String {
|
||||
text.count <= 60 ? text : String(text.prefix(57)) + "…"
|
||||
}
|
||||
|
||||
/// Auto-clear the chat composer's transient hint after 4s. Mirror
|
||||
/// of `ChatViewModel.scheduleHintClear` — uses a value snapshot
|
||||
/// rather than identity so a later toast that reuses the same
|
||||
/// string still triggers the clear once the latest value matches.
|
||||
@MainActor
|
||||
private func scheduleTransientHintClear(snapshot: String?) {
|
||||
Task { @MainActor [weak vm] in
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if vm?.transientHint == snapshot {
|
||||
vm?.transientHint = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Mirror of `ChatViewModel.expandIfProjectScoped(_:)` on Mac.
|
||||
/// `/<name> args` matching a loaded project-scoped command is
|
||||
/// expanded; everything else is sent literally.
|
||||
|
||||
@@ -84,7 +84,11 @@ struct HermesFileService: Sendable {
|
||||
inlineDiffs: bool("display.inline_diffs", default: true),
|
||||
toolProgressCommand: bool("display.tool_progress_command", default: false),
|
||||
toolPreviewLength: int("display.tool_preview_length", default: 0),
|
||||
busyInputMode: str("display.busy_input_mode", default: "interrupt")
|
||||
busyInputMode: str("display.busy_input_mode", default: "interrupt"),
|
||||
// v0.13: empty default means "key absent — agent uses its own
|
||||
// default" (English). The picker writes a real value when the
|
||||
// user explicitly chooses one.
|
||||
language: str("display.language", default: "")
|
||||
)
|
||||
|
||||
let terminal = TerminalSettings(
|
||||
@@ -131,7 +135,12 @@ struct HermesFileService: Sendable {
|
||||
sttLocalModel: str("stt.local.model", default: "base"),
|
||||
sttLocalLanguage: str("stt.local.language"),
|
||||
sttOpenAIModel: str("stt.openai.model", default: "whisper-1"),
|
||||
sttMistralModel: str("stt.mistral.model", default: "voxtral-mini-latest")
|
||||
sttMistralModel: str("stt.mistral.model", default: "voxtral-mini-latest"),
|
||||
// TODO(WS-8-Q2): Verify key names. Mirroring the elevenlabs
|
||||
// shape (`<provider>.voice_id` + `<provider>.model`); v0.13
|
||||
// source might use `tts.xai.voice` or `tts.xai.model_id`.
|
||||
ttsXAIVoiceID: str("tts.xai.voice_id"),
|
||||
ttsXAIModel: str("tts.xai.model")
|
||||
)
|
||||
|
||||
func aux(_ name: String) -> AuxiliaryModel {
|
||||
|
||||
@@ -77,27 +77,6 @@ final class ChatViewModel {
|
||||
let richChatViewModel: RichChatViewModel
|
||||
private var coordinator: Coordinator?
|
||||
|
||||
/// Capability store the chat surface reads from. Set by `ChatView`
|
||||
/// at body-evaluation time via `attachCapabilitiesStore(_:)` —
|
||||
/// `@ObservationIgnored` so capability refreshes don't force a
|
||||
/// full chat re-render. Forwards into
|
||||
/// `RichChatViewModel.capabilitiesGate` whenever the published
|
||||
/// snapshot changes; the slash menu reads through that. v2.8 /
|
||||
/// Hermes v0.13 — gates `/goal` + `/queue` slash menu rows.
|
||||
@ObservationIgnored
|
||||
var capabilitiesStore: HermesCapabilitiesStore?
|
||||
|
||||
/// Wire the Mac chat view's environment-injected capabilities store
|
||||
/// into both this VM and its child rich-chat VM. Idempotent on the
|
||||
/// pointer (re-attaching the same store is a no-op); always
|
||||
/// re-publishes the latest snapshot so a refresh that fired before
|
||||
/// the chat view became visible still lands.
|
||||
@MainActor
|
||||
func attachCapabilitiesStore(_ store: HermesCapabilitiesStore?) {
|
||||
capabilitiesStore = store
|
||||
richChatViewModel.publishCapabilities(store?.capabilities ?? .empty)
|
||||
}
|
||||
|
||||
/// `callId` of the tool call currently surfaced in the chat
|
||||
/// inspector pane, or nil when nothing is focused. Set by
|
||||
/// `ToolCallCard` taps in the transcript; cleared by the inspector's
|
||||
@@ -342,47 +321,6 @@ final class ChatViewModel {
|
||||
richChatViewModel.clearACPErrorState()
|
||||
}
|
||||
|
||||
/// Auto-clear the chat composer's transient hint after 4 s. Shared
|
||||
/// helper for `/steer`, `/goal`, and `/queue` so the toast lifetime
|
||||
/// stays consistent across non-interruptive commands.
|
||||
@MainActor
|
||||
private func scheduleHintClear() {
|
||||
let snapshot = richChatViewModel.transientHint
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if self?.richChatViewModel.transientHint == snapshot {
|
||||
self?.richChatViewModel.transientHint = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pull the slash command name + raw argument tail out of the
|
||||
/// composer text. Returns `(name: nil, args: "")` for non-slash
|
||||
/// input. Mirrors the parser shape `RichChatViewModel.parseGoalArgument`
|
||||
/// expects; kept on `ChatViewModel` (not promoted to ScarfCore)
|
||||
/// because the Mac and iOS chat surfaces compose this with their
|
||||
/// own per-platform send paths.
|
||||
static func parseSlashName(_ text: String) -> (name: String?, args: String) {
|
||||
let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard trimmed.hasPrefix("/") else { return (nil, "") }
|
||||
let withoutSlash = trimmed.dropFirst()
|
||||
if let space = withoutSlash.firstIndex(of: " ") {
|
||||
return (
|
||||
name: String(withoutSlash[..<space]),
|
||||
args: String(withoutSlash[withoutSlash.index(after: space)...])
|
||||
)
|
||||
}
|
||||
return (name: String(withoutSlash), args: "")
|
||||
}
|
||||
|
||||
/// Cap goal text in transient toasts so a 1 KB user-typed goal
|
||||
/// doesn't blow out the hint pill. The header pill applies its
|
||||
/// own 33-char cap; the toast is shorter so the hint stays
|
||||
/// glanceable.
|
||||
static func truncatedToastGoal(_ text: String) -> String {
|
||||
text.count <= 60 ? text : String(text.prefix(57)) + "…"
|
||||
}
|
||||
|
||||
@MainActor
|
||||
private func recordACPFailure(_ error: Error, client: ACPClient?, context: String) async {
|
||||
logger.error("\(context): \(error.localizedDescription)")
|
||||
@@ -637,59 +575,22 @@ final class ChatViewModel {
|
||||
// and Hermes-version-independent. v2.5.
|
||||
let wireText = expandIfProjectScoped(text)
|
||||
|
||||
// Non-interruptive slash commands keep the "Agent working…"
|
||||
// indicator off and surface a transient toast confirming the
|
||||
// command was accepted. v2.5 added `/steer`; v2.8 / Hermes
|
||||
// v0.13 adds `/goal` (lock the agent on a target across turns)
|
||||
// and `/queue` (queue a prompt for after the current turn).
|
||||
// Each gets its own optimistic side-effect on RichChatViewModel
|
||||
// so the chat header pill / queue chip update synchronously
|
||||
// without waiting for a server round-trip.
|
||||
let isNonInterruptive = richChatViewModel.isNonInterruptiveSlash(text)
|
||||
let parsed = Self.parseSlashName(text)
|
||||
switch parsed.name {
|
||||
case "goal":
|
||||
// TODO(WS-2-Q7): once a v0.13 host confirms the
|
||||
// wire-shape, this branch fires only when the host
|
||||
// advertises `hasGoals`; pre-v0.13 hosts hide the menu
|
||||
// row, but a power-user typing `/goal` directly still
|
||||
// lands here. We keep the optimistic write so the pill
|
||||
// appears synchronously — the agent's "unknown command"
|
||||
// reply on a pre-v0.13 host paints the inconsistency in
|
||||
// user-visible chat content (acceptable v1 behavior;
|
||||
// see WS-2 plan "Inconsistency caveat").
|
||||
let arg = RichChatViewModel.parseGoalArgument(parsed.args)
|
||||
switch arg {
|
||||
case .set(let goalText):
|
||||
richChatViewModel.recordActiveGoal(text: goalText)
|
||||
richChatViewModel.transientHint = "Goal locked: \(Self.truncatedToastGoal(goalText))"
|
||||
case .clear:
|
||||
richChatViewModel.recordActiveGoal(text: nil)
|
||||
richChatViewModel.transientHint = "Goal cleared."
|
||||
case .empty:
|
||||
richChatViewModel.transientHint = "Sent /goal — see the agent reply for current goal."
|
||||
}
|
||||
scheduleHintClear()
|
||||
case "queue":
|
||||
// TODO(WS-2-Q5): verify against a real v0.13 ACP host
|
||||
// that the verbatim "/queue <text>" wire shape is what
|
||||
// Hermes accepts (versus a structured arg shape). The
|
||||
// optimistic mirror logic below assumes verbatim text.
|
||||
let queuedText = parsed.args.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if !queuedText.isEmpty {
|
||||
richChatViewModel.recordQueuedPrompt(text: queuedText)
|
||||
}
|
||||
richChatViewModel.transientHint = "Queued — runs after current turn."
|
||||
scheduleHintClear()
|
||||
case "steer" where isNonInterruptive:
|
||||
// /steer is non-interruptive — the agent is still on its
|
||||
// current turn; the guidance applies after the next tool
|
||||
// call. Don't change the "Agent working..." status (it's
|
||||
// already on); show a transient toast so the user knows the
|
||||
// guidance was accepted. v2.5 / Hermes v2026.4.23+.
|
||||
let isSteer = richChatViewModel.isNonInterruptiveSlash(text)
|
||||
if isSteer {
|
||||
richChatViewModel.transientHint = "Guidance queued — applies after the next tool call."
|
||||
scheduleHintClear()
|
||||
default:
|
||||
// Regular interruptive prompt (or an unrecognized slash).
|
||||
// Don't flip "Agent working…" for any other
|
||||
// non-interruptive command (defensive; matches the
|
||||
// legacy contract).
|
||||
if !isNonInterruptive { acpStatus = ACPPhase.agentWorking }
|
||||
Task { @MainActor [weak self] in
|
||||
try? await Task.sleep(nanoseconds: 4_000_000_000)
|
||||
if self?.richChatViewModel.transientHint == "Guidance queued — applies after the next tool call." {
|
||||
self?.richChatViewModel.transientHint = nil
|
||||
}
|
||||
}
|
||||
} else {
|
||||
acpStatus = ACPPhase.agentWorking
|
||||
}
|
||||
acpPromptTask = Task { @MainActor in
|
||||
do {
|
||||
@@ -707,7 +608,7 @@ final class ChatViewModel {
|
||||
// notifier handles the foreground/disabled gating;
|
||||
// we just hand it the latest assistant text and
|
||||
// session title for the body line.
|
||||
if !isNonInterruptive {
|
||||
if !isSteer {
|
||||
let preview = richChatViewModel.messages
|
||||
.last(where: { $0.isAssistant })?
|
||||
.content ?? ""
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
/// Header chip that surfaces prompts the user has queued via
|
||||
/// `/queue …` (Hermes v0.13). Tap → popover listing the queued
|
||||
/// prompt previews + their relative timestamps.
|
||||
///
|
||||
/// The chip is OPTIMISTIC — it's a Scarf-side mirror of what the user
|
||||
/// typed. Hermes owns the authoritative queue server-side. The popover
|
||||
/// header makes that explicit so the user understands per-entry
|
||||
/// removal isn't supported (Hermes has no remove-by-id verb), and the
|
||||
/// v2.8.0 plan removed the "Clear all" button rather than ship one
|
||||
/// that would lie about its effect on server-side state. See WS-2 plan
|
||||
/// Q2 for the wire-shape question that drove that decision.
|
||||
struct ChatQueueIndicator: View {
|
||||
let queuedPrompts: [HermesQueuedPrompt]
|
||||
@State private var isPopoverShown = false
|
||||
|
||||
var body: some View {
|
||||
if queuedPrompts.isEmpty {
|
||||
EmptyView()
|
||||
} else {
|
||||
chipButton
|
||||
.popover(isPresented: $isPopoverShown, arrowEdge: .bottom) {
|
||||
queuePopover
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var chipButton: some View {
|
||||
Button {
|
||||
isPopoverShown = true
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "tray.full")
|
||||
Text("\(queuedPrompts.count) queued")
|
||||
}
|
||||
.scarfStyle(.caption)
|
||||
.padding(.horizontal, ScarfSpace.s2)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(ScarfColor.warning.opacity(0.16)))
|
||||
.foregroundStyle(ScarfColor.warning)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.help("Prompts waiting to run after the current turn finishes")
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var queuePopover: some View {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
Text("Queued prompts")
|
||||
.scarfStyle(.headline)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
Text("Local view — Hermes manages the actual queue server-side. The next prompt runs automatically when the current turn finishes.")
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
ScarfDivider()
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
|
||||
ForEach(Array(queuedPrompts.enumerated()), id: \.element.id) { index, prompt in
|
||||
queueRow(prompt, position: index + 1)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
.frame(maxHeight: 220)
|
||||
}
|
||||
.padding(ScarfSpace.s4)
|
||||
.frame(width: 360)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func queueRow(_ prompt: HermesQueuedPrompt, position: Int) -> some View {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: ScarfSpace.s2) {
|
||||
Text("#\(position)")
|
||||
.scarfStyle(.captionUppercase)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
Text(prompt.queuedAt, style: .relative)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.monospacedDigit()
|
||||
}
|
||||
Text(prompt.text)
|
||||
.scarfStyle(.body)
|
||||
.foregroundStyle(ScarfColor.foregroundPrimary)
|
||||
.lineLimit(3)
|
||||
.truncationMode(.tail)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
@@ -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,10 @@ 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
|
||||
capabilities: capabilitiesStore?.capabilities ?? .empty
|
||||
)
|
||||
Divider()
|
||||
|
||||
@@ -61,8 +61,7 @@ struct ChatTranscriptPane: View {
|
||||
onSend: onSend,
|
||||
isEnabled: isEnabled,
|
||||
commands: richChat.availableCommands,
|
||||
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu,
|
||||
isAgentWorking: richChat.isAgentWorking
|
||||
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
|
||||
)
|
||||
.id(richChat.sessionId ?? "scarf.chat.no-session")
|
||||
}
|
||||
|
||||
@@ -5,12 +5,6 @@ struct ChatView: View {
|
||||
@Environment(ChatViewModel.self) private var viewModel
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
/// Capabilities store for the active server (injected on
|
||||
/// `ContextBoundRoot`). Forwarded into `ChatViewModel` so the
|
||||
/// rich-chat slash menu can gate v0.13 surfaces (`/goal`, `/queue`,
|
||||
/// `/steer` on idle). Nil during harness scenarios; treated the
|
||||
/// same as `.empty` capabilities.
|
||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||
@State private var showErrorDetails = false
|
||||
|
||||
/// Side-pane visibility toggles (issue #58). Drive the new
|
||||
@@ -51,15 +45,6 @@ struct ChatView: View {
|
||||
.navigationTitle(
|
||||
viewModel.currentProjectName.map { "Chat · \($0)" } ?? "Chat"
|
||||
)
|
||||
// Forward the env-injected capabilities store into the chat VM
|
||||
// on every refresh tick so the rich-chat slash menu picks up
|
||||
// v0.13 surfaces the moment the host advertises them. The id
|
||||
// value is the capabilities-line string — a stable identity
|
||||
// that flips exactly when the detector fires. Nil store ↔
|
||||
// `.empty` capabilities, which is what the VM defaults to.
|
||||
.task(id: capabilitiesStore?.capabilities.versionLine ?? "") {
|
||||
viewModel.attachCapabilitiesStore(capabilitiesStore)
|
||||
}
|
||||
.task {
|
||||
await viewModel.loadRecentSessions()
|
||||
viewModel.refreshCredentialPreflight()
|
||||
|
||||
@@ -16,11 +16,6 @@ struct RichChatInputBar: View {
|
||||
let isEnabled: Bool
|
||||
var commands: [HermesSlashCommand] = []
|
||||
var showCompressButton: Bool = false
|
||||
/// Whether the agent is currently mid-turn. Used to grey-out
|
||||
/// `/steer` in the slash menu on idle pre-v0.13 hosts (where the
|
||||
/// command silently no-ops). v0.13+ hosts allow `/steer` on idle
|
||||
/// and the row stays interactive regardless of `isAgentWorking`.
|
||||
var isAgentWorking: Bool = false
|
||||
|
||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||
|
||||
@@ -57,8 +52,6 @@ struct RichChatInputBar: View {
|
||||
SlashCommandMenu(
|
||||
commands: filteredCommands,
|
||||
agentHasCommands: !commands.isEmpty,
|
||||
disabledCommandNames: disabledMenuCommandNames,
|
||||
disabledReason: disabledMenuReason,
|
||||
selectedIndex: $selectedIndex,
|
||||
onSelect: insertCommand
|
||||
)
|
||||
@@ -399,27 +392,6 @@ struct RichChatInputBar: View {
|
||||
SlashCommandMenu.filter(commands: commands, query: menuQuery)
|
||||
}
|
||||
|
||||
/// Names of menu rows that should render greyed-out + ignore taps.
|
||||
/// v2.8 / Hermes v0.13: `/steer` is greyed only when the connected
|
||||
/// host is pre-v0.13 AND the session is idle. Pre-v0.13 hosts
|
||||
/// silently no-op `/steer` outside an active turn — surfacing the
|
||||
/// row as "use during a turn" is friendlier than letting the user
|
||||
/// click and see nothing happen. v0.13+ hosts allow steer-on-idle
|
||||
/// (the command just sends as a regular prompt) so the row stays
|
||||
/// interactive there.
|
||||
private var disabledMenuCommandNames: Set<String> {
|
||||
let hasSteerOnIdle = capabilitiesStore?.capabilities.hasACPSteerOnIdle ?? false
|
||||
if !isAgentWorking && !hasSteerOnIdle {
|
||||
return ["steer"]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
private var disabledMenuReason: String? {
|
||||
guard !disabledMenuCommandNames.isEmpty else { return nil }
|
||||
return "Use `/steer` while the agent is working — your Hermes version doesn't support steering on idle sessions."
|
||||
}
|
||||
|
||||
private func updateMenuState() {
|
||||
let shouldShow = shouldShowMenu
|
||||
|
||||
|
||||
@@ -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,17 +25,11 @@ struct SessionInfoBar: View {
|
||||
/// name. Nil for non-project chats and for projects that aren't
|
||||
/// git repos.
|
||||
var gitBranch: String? = nil
|
||||
/// Active locked goal (Hermes v0.13 `/goal`). Nil hides the pill.
|
||||
/// Optimistic — set by `RichChatViewModel.recordActiveGoal(text:)`
|
||||
/// when the user sends `/goal …`.
|
||||
var activeGoal: HermesActiveGoal? = nil
|
||||
/// Invoked when the user picks "Clear goal" from the goal pill's
|
||||
/// context menu. Caller dispatches `/goal --clear` so the optimistic
|
||||
/// pill clear and the server-side authoritative state stay in sync.
|
||||
var onClearGoal: (() -> Void)? = nil
|
||||
/// 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; 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.
|
||||
@@ -73,42 +72,6 @@ struct SessionInfoBar: View {
|
||||
}
|
||||
}
|
||||
|
||||
// Goal pill (v2.8 / Hermes v0.13). `.info` keeps it
|
||||
// visually decodable from the rust accent (project /
|
||||
// branch) and the warning amber (queue chip). The
|
||||
// pill renders only when `activeGoal` is non-nil —
|
||||
// pre-v0.13 hosts can't reach the `/goal` send path
|
||||
// through the slash menu (it's filtered out in
|
||||
// `availableCommands`), so the pill stays absent there
|
||||
// by transitive impossibility.
|
||||
if let activeGoal {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: "scope")
|
||||
Text(Self.truncatedGoal(activeGoal.text))
|
||||
}
|
||||
.scarfStyle(.caption)
|
||||
.padding(.horizontal, ScarfSpace.s2)
|
||||
.padding(.vertical, 2)
|
||||
.background(Capsule().fill(ScarfColor.info.opacity(0.16)))
|
||||
.foregroundStyle(ScarfColor.info)
|
||||
.help("Goal locked: \(activeGoal.text)")
|
||||
.contextMenu {
|
||||
if let onClearGoal {
|
||||
Button("Clear goal", role: .destructive, action: onClearGoal)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Queue chip (v2.8 / Hermes v0.13). Local mirror only —
|
||||
// Hermes is the authoritative owner of the actual
|
||||
// queue. Per-entry deletion isn't exposed (Hermes has
|
||||
// no remove-by-id verb), and the v2.8.0 plan drops the
|
||||
// global "Clear all" button to avoid lying about
|
||||
// server-side state. The popover is read-only.
|
||||
if !queuedPrompts.isEmpty {
|
||||
ChatQueueIndicator(queuedPrompts: queuedPrompts)
|
||||
}
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(isWorking ? ScarfColor.success : ScarfColor.foregroundFaint)
|
||||
@@ -143,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")
|
||||
@@ -181,11 +159,4 @@ struct SessionInfoBar: View {
|
||||
private func formatTokens(_ count: Int) -> String {
|
||||
count.formatted(.number.notation(.compactName).precision(.fractionLength(0...1)))
|
||||
}
|
||||
|
||||
/// Cap goal text in the chip to keep the SessionInfoBar from
|
||||
/// wrapping when the user locks a long goal. Full goal text is
|
||||
/// available in the tooltip via `.help(...)`.
|
||||
static func truncatedGoal(_ text: String) -> String {
|
||||
text.count <= 36 ? text : String(text.prefix(33)) + "…"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,13 +11,6 @@ struct SlashCommandMenu: View {
|
||||
/// Whether the agent advertised any commands at all. Lets us distinguish
|
||||
/// "agent hasn't sent commands yet" from "filter matched nothing".
|
||||
let agentHasCommands: Bool
|
||||
/// Names that render greyed-out + ignore taps. v2.8 uses this only
|
||||
/// for `/steer` on pre-v0.13 idle sessions; v0.13 hosts allow steer
|
||||
/// on idle and the set is empty.
|
||||
var disabledCommandNames: Set<String> = []
|
||||
/// Tooltip shown on disabled rows. Reused per-row in v2.8 — only
|
||||
/// one disabled case ships, so a single shared string is enough.
|
||||
var disabledReason: String? = nil
|
||||
@Binding var selectedIndex: Int
|
||||
var onSelect: (HermesSlashCommand) -> Void
|
||||
|
||||
@@ -57,17 +50,13 @@ struct SlashCommandMenu: View {
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(commands.enumerated()), id: \.element.id) { index, command in
|
||||
let isDisabled = disabledCommandNames.contains(command.name)
|
||||
SlashCommandRow(
|
||||
command: command,
|
||||
isSelected: index == selectedIndex,
|
||||
isDisabled: isDisabled,
|
||||
disabledReason: isDisabled ? disabledReason : nil
|
||||
isSelected: index == selectedIndex
|
||||
)
|
||||
.id(index)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
guard !isDisabled else { return }
|
||||
selectedIndex = index
|
||||
onSelect(command)
|
||||
}
|
||||
@@ -88,8 +77,6 @@ struct SlashCommandMenu: View {
|
||||
private struct SlashCommandRow: View {
|
||||
let command: HermesSlashCommand
|
||||
let isSelected: Bool
|
||||
var isDisabled: Bool = false
|
||||
var disabledReason: String? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
@@ -100,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
|
||||
// <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)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
}
|
||||
@@ -120,19 +116,11 @@ private struct SlashCommandRow: View {
|
||||
.foregroundStyle(ScarfColor.foregroundMuted)
|
||||
.lineLimit(2)
|
||||
}
|
||||
if isDisabled, let reason = disabledReason {
|
||||
Text(reason)
|
||||
.scarfStyle(.caption)
|
||||
.foregroundStyle(ScarfColor.foregroundFaint)
|
||||
.lineLimit(2)
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, ScarfSpace.s3)
|
||||
.padding(.vertical, ScarfSpace.s2)
|
||||
.background(isSelected ? ScarfColor.accentTint : Color.clear)
|
||||
.opacity(isDisabled ? 0.55 : 1.0)
|
||||
.help(isDisabled ? (disabledReason ?? "") : "")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,8 +29,31 @@ final class SettingsViewModel {
|
||||
// that no-ops on older hosts is low compared to gating overhead.
|
||||
var terminalBackends = ["local", "docker", "singularity", "modal", "daytona", "ssh", "vercel"]
|
||||
var browserBackends = ["browseruse", "firecrawl", "local"]
|
||||
var ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts", "piper"]
|
||||
// v0.13: `xai` joins the TTS provider list. xAI shipped TTS earlier
|
||||
// (v0.12) but the v0.13 add-on is custom voice cloning — see
|
||||
// `HermesCapabilities.hasXAIVoiceCloning` and the badge in VoiceTab.
|
||||
// The provider option itself is ungated so pre-v0.13 hosts with xAI
|
||||
// keys can still pick it.
|
||||
var ttsProviders = ["edge", "elevenlabs", "openai", "minimax", "mistral", "neutts", "piper", "xai"]
|
||||
var sttProviders = ["local", "groq", "openai", "mistral"]
|
||||
/// Static-message translation languages honored by Hermes v0.13's
|
||||
/// `display.language` key. The first row's empty value writes no
|
||||
/// key — equivalent to "Hermes default" — while explicit `en` writes
|
||||
/// the code so users who care about determinism can pin it. Keep the
|
||||
/// label list in sync with the Hermes v0.13 release notes; new
|
||||
/// languages should be appended in alphabetical order by display
|
||||
/// label so the picker stays scannable.
|
||||
var displayLanguages: [(code: String, label: String)] = [
|
||||
("", "English (default)"),
|
||||
("en", "English"),
|
||||
("zh", "中文 (Chinese)"),
|
||||
("ja", "日本語 (Japanese)"),
|
||||
("de", "Deutsch (German)"),
|
||||
("es", "Español (Spanish)"),
|
||||
("fr", "Français (French)"),
|
||||
("uk", "Українська (Ukrainian)"),
|
||||
("tr", "Türkçe (Turkish)"),
|
||||
]
|
||||
var memoryProviders = ["", "honcho", "openviking", "mem0", "hindsight", "holographic", "retaindb", "byterover", "supermemory"]
|
||||
var saveMessage: String?
|
||||
var isLoading = false
|
||||
@@ -104,6 +127,10 @@ final class SettingsViewModel {
|
||||
func setToolProgressCommand(_ value: Bool) { setSetting("display.tool_progress_command", value: value ? "true" : "false") }
|
||||
func setToolPreviewLength(_ value: Int) { setSetting("display.tool_preview_length", value: String(value)) }
|
||||
func setBusyInputMode(_ value: String) { setSetting("display.busy_input_mode", value: value) }
|
||||
/// v0.13: `display.language` for static-message translations. Empty
|
||||
/// string writes "" via `hermes config set` which Hermes treats as
|
||||
/// "use default"; explicit codes pin the language.
|
||||
func setDisplayLanguage(_ value: String) { setSetting("display.language", value: value) }
|
||||
|
||||
// MARK: - Agent
|
||||
|
||||
@@ -158,6 +185,10 @@ final class SettingsViewModel {
|
||||
func setTTSOpenAIVoice(_ value: String) { setSetting("tts.openai.voice", value: value) }
|
||||
func setTTSNeuTTSModel(_ value: String) { setSetting("tts.neutts.model", value: value) }
|
||||
func setTTSNeuTTSDevice(_ value: String) { setSetting("tts.neutts.device", value: value) }
|
||||
// v0.13: xAI TTS / Custom Voices. TODO(WS-8-Q2): grep-verify key
|
||||
// names against `~/.hermes/hermes-agent/hermes_cli/voice/tts.py`.
|
||||
func setTTSXAIVoiceID(_ value: String) { setSetting("tts.xai.voice_id", value: value) }
|
||||
func setTTSXAIModel(_ value: String) { setSetting("tts.xai.model", value: value) }
|
||||
func setSTTEnabled(_ value: Bool) { setSetting("stt.enabled", value: value ? "true" : "false") }
|
||||
func setSTTProvider(_ value: String) { setSetting("stt.provider", value: value) }
|
||||
func setSTTLocalModel(_ value: String) { setSetting("stt.local.model", value: value) }
|
||||
|
||||
@@ -152,8 +152,23 @@ struct PickerRow: View {
|
||||
let label: String
|
||||
let selection: String
|
||||
let options: [String]
|
||||
let optionLabel: ((String) -> String)?
|
||||
let onChange: (String) -> Void
|
||||
|
||||
init(
|
||||
label: String,
|
||||
selection: String,
|
||||
options: [String],
|
||||
optionLabel: ((String) -> String)? = nil,
|
||||
onChange: @escaping (String) -> Void
|
||||
) {
|
||||
self.label = label
|
||||
self.selection = selection
|
||||
self.options = options
|
||||
self.optionLabel = optionLabel
|
||||
self.onChange = onChange
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
SettingsRowLabel(label: label)
|
||||
@@ -162,7 +177,7 @@ struct PickerRow: View {
|
||||
set: { onChange($0) }
|
||||
)) {
|
||||
ForEach(options, id: \.self) { option in
|
||||
Text(option.isEmpty ? "(none)" : option).tag(option)
|
||||
Text(displayLabel(for: option)).tag(option)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 250)
|
||||
@@ -170,6 +185,13 @@ struct PickerRow: View {
|
||||
}
|
||||
.settingsRowChrome()
|
||||
}
|
||||
|
||||
private func displayLabel(for option: String) -> String {
|
||||
if let mapper = optionLabel {
|
||||
return mapper(option)
|
||||
}
|
||||
return option.isEmpty ? "(none)" : option
|
||||
}
|
||||
}
|
||||
|
||||
struct ToggleRow: View {
|
||||
|
||||
@@ -131,6 +131,8 @@ struct AdvancedTab: View {
|
||||
isOn: viewModel.config.redactionEnabled
|
||||
) { viewModel.setSetting("redaction.enabled", value: $0 ? "true" : "false") }
|
||||
|
||||
redactionDefaultsHint
|
||||
|
||||
ToggleRow(
|
||||
label: "Runtime metadata footer",
|
||||
isOn: viewModel.config.runtimeMetadataFooter
|
||||
@@ -138,6 +140,30 @@ struct AdvancedTab: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline hint below the redaction toggle. The server-side default
|
||||
/// flipped from OFF (v0.12) to ON (v0.13), but Scarf's parser still
|
||||
/// reads "absent key" as `false` — meaning a v0.13 host with no
|
||||
/// explicit key in `config.yaml` shows the toggle OFF while the
|
||||
/// agent treats redaction as ON. Hint copy disambiguates so users
|
||||
/// can tell what's actually happening server-side.
|
||||
@ViewBuilder
|
||||
private var redactionDefaultsHint: some View {
|
||||
let isV013 = capabilitiesStore?.capabilities.isV013OrLater ?? false
|
||||
HStack {
|
||||
Text("")
|
||||
.font(.caption)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
Text(isV013
|
||||
? "Recommended: ON. Hermes v0.13+ defaults to redacting secrets unless you opt out."
|
||||
: "Default OFF in Hermes v0.12. Toggle ON to redact secrets in logs and shares.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var backupSection: some View {
|
||||
SettingsSection(title: "Backup & Restore", icon: "externaldrive") {
|
||||
HStack {
|
||||
|
||||
@@ -7,6 +7,7 @@ import ScarfCore
|
||||
struct GeneralTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Model", icon: "cpu") {
|
||||
@@ -39,6 +40,20 @@ struct GeneralTab: View {
|
||||
|
||||
SettingsSection(title: "Locale", icon: "globe.americas") {
|
||||
EditableTextField(label: "Timezone (IANA)", value: viewModel.config.timezone) { viewModel.setTimezone($0) }
|
||||
// v0.13: `display.language` picker. Hidden on pre-v0.13 hosts
|
||||
// because writing the key would no-op silently. Two "English"
|
||||
// entries by design — empty string preserves "no key" semantics
|
||||
// (Hermes-default), explicit `en` pins it.
|
||||
if capabilitiesStore?.capabilities.hasDisplayLanguage == true {
|
||||
PickerRow(
|
||||
label: "Display language",
|
||||
selection: viewModel.config.display.language,
|
||||
options: viewModel.displayLanguages.map(\.code),
|
||||
optionLabel: { code in
|
||||
viewModel.displayLanguages.first { $0.code == code }?.label ?? code
|
||||
}
|
||||
) { viewModel.setDisplayLanguage($0) }
|
||||
}
|
||||
}
|
||||
|
||||
UpdatesSection()
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
import ScarfDesign
|
||||
|
||||
/// Voice tab — push-to-talk + TTS + STT provider settings.
|
||||
struct VoiceTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
@Environment(\.hermesCapabilities) private var capabilitiesStore
|
||||
|
||||
var body: some View {
|
||||
SettingsSection(title: "Push-to-Talk", icon: "mic") {
|
||||
@@ -28,6 +30,16 @@ struct VoiceTab: View {
|
||||
case "neutts":
|
||||
EditableTextField(label: "Model", value: viewModel.config.voice.ttsNeuTTSModel) { viewModel.setTTSNeuTTSModel($0) }
|
||||
PickerRow(label: "Device", selection: viewModel.config.voice.ttsNeuTTSDevice, options: ["cpu", "cuda"]) { viewModel.setTTSNeuTTSDevice($0) }
|
||||
case "xai":
|
||||
// v0.13: xAI TTS surface. Voice ID + Model are always
|
||||
// visible (xAI TTS shipped earlier); the cloning-supported
|
||||
// badge is gated on `hasXAIVoiceCloning` so pre-v0.13 hosts
|
||||
// see the input rows but no cloning advertisement.
|
||||
EditableTextField(label: "Voice ID", value: viewModel.config.voice.ttsXAIVoiceID) { viewModel.setTTSXAIVoiceID($0) }
|
||||
EditableTextField(label: "Model", value: viewModel.config.voice.ttsXAIModel) { viewModel.setTTSXAIModel($0) }
|
||||
if capabilitiesStore?.capabilities.hasXAIVoiceCloning == true {
|
||||
xaiCloningBadge
|
||||
}
|
||||
default:
|
||||
EmptyView()
|
||||
}
|
||||
@@ -49,4 +61,24 @@ struct VoiceTab: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Inline hint chip+caption shown below xAI's Voice ID + Model fields
|
||||
/// on v0.13+. References `hermes voice` because Scarf doesn't manage
|
||||
/// cloned voices in-app yet — the badge is discovery-only. Out-of-scope
|
||||
/// for v2.8: an in-app cloned-voice manager (would be its own feature).
|
||||
@ViewBuilder
|
||||
private var xaiCloningBadge: some View {
|
||||
HStack(alignment: .center, spacing: 8) {
|
||||
Text("")
|
||||
.font(.caption)
|
||||
.frame(width: 160, alignment: .trailing)
|
||||
ScarfBadge("Cloning supported", kind: .info)
|
||||
Text("Manage cloned voices in your terminal: `hermes voice` (xAI subcommands).")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user