diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesActiveGoal.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesActiveGoal.swift new file mode 100644 index 0000000..ce8b556 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesActiveGoal.swift @@ -0,0 +1,26 @@ +import Foundation + +/// Optimistic local mirror of the agent's currently-locked goal (set via +/// the `/goal ` 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 + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesQueuedPrompt.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesQueuedPrompt.swift new file mode 100644 index 0000000..398f20f --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesQueuedPrompt.swift @@ -0,0 +1,23 @@ +import Foundation + +/// One queued prompt the user has staged via `/queue ` (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 + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index 5debd36..f4e7ced 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -248,15 +248,73 @@ 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: "", source: .acpNonInterruptive + ), + HermesSlashCommand( + name: "goal", + description: "Lock the agent on a goal that persists across turns", + argumentHint: "", + source: .acpNonInterruptive + ), + HermesSlashCommand( + name: "queue", + description: "Queue a prompt to run after the current turn", + argumentHint: "", + 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 @@ -318,12 +376,94 @@ public final class RichChatViewModel { !acpNames.contains($0.name) && !projectNames.contains($0.name) } let occupied = acpNames.union(projectNames).union(Set(quicks.map(\.name))) - let nonInterruptive = Self.nonInterruptiveCommands.filter { - !occupied.contains($0.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 = 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 `. + /// 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 `). 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 @@ -474,6 +614,14 @@ public final class RichChatViewModel { 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() } @@ -812,6 +960,22 @@ public final class RichChatViewModel { acpThoughtTokens += response.thoughtTokens acpCachedReadTokens += response.cachedReadTokens 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)` diff --git a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M9SlashCommandTests.swift b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M9SlashCommandTests.swift index b850100..a1e78ba 100644 --- a/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M9SlashCommandTests.swift +++ b/scarf/Packages/ScarfCore/Tests/ScarfCoreTests/M9SlashCommandTests.swift @@ -241,6 +241,150 @@ 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 { diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index b552c56..dfd7c25 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -109,6 +109,17 @@ 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 @@ -1307,18 +1318,48 @@ final class ChatController { // even when they didn't type any caption. vm.addUserMessage(text: "[image attached]") } - // /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." - 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 - } + // 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): + vm.transientHint = "Guidance queued — applies after the next tool call." + scheduleTransientHintClear(snapshot: vm.transientHint) + default: + break } // Project-scoped slash commands expand client-side: the user // bubble shows the literal `/ args` they typed (above); @@ -1341,6 +1382,43 @@ final class ChatController { } } + /// Pull `(name, argTail)` out of a `/ [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[.. 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. /// `/ args` matching a loaded project-scoped command is /// expanded; everything else is sent literally. diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 1ed2d77..d89e80d 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -77,6 +77,27 @@ 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 @@ -321,6 +342,47 @@ 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[.. 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)") @@ -575,22 +637,59 @@ final class ChatViewModel { // and Hermes-version-independent. v2.5. let wireText = expandIfProjectScoped(text) - // /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." - 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 - } + // 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." } - } else { - acpStatus = ACPPhase.agentWorking + scheduleHintClear() + case "queue": + // TODO(WS-2-Q5): verify against a real v0.13 ACP host + // that the verbatim "/queue " 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: + 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 } } acpPromptTask = Task { @MainActor in do { @@ -608,7 +707,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 !isSteer { + if !isNonInterruptive { let preview = richChatViewModel.messages .last(where: { $0.isAssistant })? .content ?? "" diff --git a/scarf/scarf/Features/Chat/Views/ChatQueueIndicator.swift b/scarf/scarf/Features/Chat/Views/ChatQueueIndicator.swift new file mode 100644 index 0000000..1366f6d --- /dev/null +++ b/scarf/scarf/Features/Chat/Views/ChatQueueIndicator.swift @@ -0,0 +1,95 @@ +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) + } +} diff --git a/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift b/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift index eaa8294..301a5b5 100644 --- a/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift +++ b/scarf/scarf/Features/Chat/Views/ChatTranscriptPane.swift @@ -21,7 +21,10 @@ struct ChatTranscriptPane: View { acpOutputTokens: richChat.acpOutputTokens, acpThoughtTokens: richChat.acpThoughtTokens, projectName: chatViewModel.currentProjectName, - gitBranch: chatViewModel.currentGitBranch + gitBranch: chatViewModel.currentGitBranch, + activeGoal: richChat.activeGoal, + onClearGoal: { chatViewModel.sendText("/goal --clear") }, + queuedPrompts: richChat.queuedPrompts ) Divider() @@ -58,7 +61,8 @@ struct ChatTranscriptPane: View { onSend: onSend, isEnabled: isEnabled, commands: richChat.availableCommands, - showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu + showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu, + isAgentWorking: richChat.isAgentWorking ) .id(richChat.sessionId ?? "scarf.chat.no-session") } diff --git a/scarf/scarf/Features/Chat/Views/ChatView.swift b/scarf/scarf/Features/Chat/Views/ChatView.swift index 042edc8..ef23977 100644 --- a/scarf/scarf/Features/Chat/Views/ChatView.swift +++ b/scarf/scarf/Features/Chat/Views/ChatView.swift @@ -5,6 +5,12 @@ 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 @@ -45,6 +51,15 @@ 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() diff --git a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift index 14619a1..a428ff6 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatInputBar.swift @@ -16,6 +16,11 @@ 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 @@ -52,6 +57,8 @@ struct RichChatInputBar: View { SlashCommandMenu( commands: filteredCommands, agentHasCommands: !commands.isEmpty, + disabledCommandNames: disabledMenuCommandNames, + disabledReason: disabledMenuReason, selectedIndex: $selectedIndex, onSelect: insertCommand ) @@ -392,6 +399,27 @@ 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 { + 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 diff --git a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift index 33a2d5e..b448207 100644 --- a/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift +++ b/scarf/scarf/Features/Chat/Views/SessionInfoBar.swift @@ -20,6 +20,17 @@ 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] = [] /// Active Hermes profile name (issue #50). Resolved on each body /// re-evaluation; the resolver caches for 5s so this is cheap. @@ -62,6 +73,42 @@ 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) @@ -134,4 +181,11 @@ 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)) + "…" + } } diff --git a/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift b/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift index 22eb33f..5cb6e43 100644 --- a/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift +++ b/scarf/scarf/Features/Chat/Views/SlashCommandMenu.swift @@ -11,6 +11,13 @@ 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 = [] + /// 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 @@ -50,13 +57,17 @@ 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 + isSelected: index == selectedIndex, + isDisabled: isDisabled, + disabledReason: isDisabled ? disabledReason : nil ) .id(index) .contentShape(Rectangle()) .onTapGesture { + guard !isDisabled else { return } selectedIndex = index onSelect(command) } @@ -77,6 +88,8 @@ 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) { @@ -107,11 +120,19 @@ 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 ?? "") : "") } }