feat(chat): /steer non-interruptive support (Phase 2.1)

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) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 08:56:47 +02:00
parent 79a350d793
commit a9bd51bf05
4 changed files with 121 additions and 2 deletions
@@ -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: "<guidance>",
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[..<space])
} else {
name = String(withoutSlash)
}
return Self.nonInterruptiveCommands.contains { $0.name == name }
}
/// Look up the full project-scoped command payload by slash trigger.