From a9bd51bf05b2d754260872f2d4039ab2e3e0162c Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 25 Apr 2026 08:56:47 +0200 Subject: [PATCH] feat(chat): /steer non-interruptive support (Phase 2.1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hermes v2026.4.23 introduces /steer — mid-run guidance the agent applies after the next tool call without interrupting the current turn. Surface it as a first-class slash command in both Mac and iOS chat menus with non-interruptive send semantics. ScarfCore RichChatViewModel: - nonInterruptiveCommands static (currently just /steer) merged into availableCommands at the end of the menu. - HermesSlashCommand.Source.acpNonInterruptive case carries the flag through to the menu UI. - transientHint: String? property for short-lived composer toasts. - isNonInterruptiveSlash(_ text: String) -> Bool helper for the send paths to detect /steer-shaped invocations. Mac ChatViewModel.sendViaACP: - /steer-shaped sends skip the "Agent working..." status update (the agent is already on its current turn) and set a 4-second transientHint "Guidance queued — applies after the next tool call." Mac RichChatView: - New steeringToast() above the input bar renders the hint when set; tinted pill with arrow icon, opacity transition. iOS ChatController.send + ChatView: - Same isNonInterruptiveSlash check surfaces the toast above the composer; auto-clears via the same 4s Task pattern. - steeringToast() helper view in ChatView. Verified: ScarfCore + Mac + iOS builds clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ViewModels/RichChatViewModel.swift | 46 ++++++++++++++++++- scarf/Scarf iOS/Chat/ChatView.swift | 36 +++++++++++++++ .../Chat/ViewModels/ChatViewModel.swift | 18 +++++++- .../Features/Chat/Views/RichChatView.swift | 23 ++++++++++ 4 files changed, 121 insertions(+), 2 deletions(-) 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)) + } }