diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index c2bebeb..d89181f 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -197,8 +197,31 @@ public final class RichChatViewModel { /// `ChatViewModel.sendPrompt` and needs the body + model override. public private(set) var projectScopedCommands: [ProjectSlashCommand] = [] + /// Hardcoded ACP-native commands that don't interrupt the current + /// turn. v2.5 ships `/steer` as the flagship — applies user + /// guidance after the next tool call without aborting. Fronted by + /// 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. + public static let nonInterruptiveCommands: [HermesSlashCommand] = [ + HermesSlashCommand( + name: "steer", + description: "Nudge the agent mid-run (applies after the next tool call)", + argumentHint: "", + source: .acpNonInterruptive + ) + ] + + /// 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 + /// model just owns the value. + public var transientHint: String? + /// Merged slash-menu list. Precedence: **ACP > project-scoped > /// quick_commands** (most specific source wins). De-duplicated by name. + /// Non-interruptive ACP commands (`/steer`) are always appended at + /// the end so they don't crowd the more frequently-used options. public var availableCommands: [HermesSlashCommand] { let acpNames = Set(acpCommands.map(\.name)) let projectAsHermes: [HermesSlashCommand] = projectScopedCommands @@ -215,7 +238,28 @@ public final class RichChatViewModel { let quicks = quickCommands.filter { !acpNames.contains($0.name) && !projectNames.contains($0.name) } - return acpCommands + projectAsHermes + quicks + let occupied = acpNames.union(projectNames).union(Set(quicks.map(\.name))) + let nonInterruptive = Self.nonInterruptiveCommands.filter { + !occupied.contains($0.name) + } + return acpCommands + projectAsHermes + quicks + nonInterruptive + } + + /// 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 + /// `/steer` (the agent's still on its current turn). + public func isNonInterruptiveSlash(_ text: String) -> Bool { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("/") else { return false } + let withoutSlash = trimmed.dropFirst() + let name: String + if let space = withoutSlash.firstIndex(of: " ") { + name = String(withoutSlash[.. some View { + HStack(spacing: 6) { + Image(systemName: "arrowshape.turn.up.right.fill") + .foregroundStyle(.tint) + .font(.caption) + Text(hint) + .font(.caption) + .foregroundStyle(.primary) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(.tint.opacity(0.12)) + .transition(.opacity) + } + private var composer: some View { HStack(alignment: .bottom, spacing: 8) { TextField( @@ -557,6 +580,19 @@ final class ChatController { guard !sessionId.isEmpty else { return } draft = "" vm.addUserMessage(text: 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." + 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 `/ args` they typed (above); // Hermes receives the expanded prompt template body. Other diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index 27cf596..69f24de 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -315,7 +315,23 @@ final class ChatViewModel { // and Hermes-version-independent. v2.5. let wireText = expandIfProjectScoped(text) - acpStatus = "Agent working..." + // /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 + } + } + } else { + acpStatus = "Agent working..." + } acpPromptTask = Task { @MainActor in do { let result = try await client.sendPrompt(sessionId: sessionId, text: wireText) diff --git a/scarf/scarf/Features/Chat/Views/RichChatView.swift b/scarf/scarf/Features/Chat/Views/RichChatView.swift index 863fd85..0897ed8 100644 --- a/scarf/scarf/Features/Chat/Views/RichChatView.swift +++ b/scarf/scarf/Features/Chat/Views/RichChatView.swift @@ -46,6 +46,9 @@ struct RichChatView: View { ) Divider() + if let hint = richChat.transientHint { + steeringToast(hint) + } RichChatInputBar( onSend: { text in onSend(text) @@ -75,4 +78,24 @@ struct RichChatView: View { } } } + + /// Soft pill above the composer that confirms a non-interruptive + /// command (e.g. `/steer`) was received. Auto-clears after a short + /// delay (managed by `ChatViewModel`); presence in the model is + /// what drives this view. + private func steeringToast(_ hint: String) -> some View { + HStack(spacing: 6) { + Image(systemName: "arrowshape.turn.up.right.fill") + .foregroundStyle(.tint) + .font(.caption) + Text(hint) + .font(.caption) + .foregroundStyle(.primary) + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(.tint.opacity(0.12)) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } }