mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
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:
@@ -197,8 +197,31 @@ public final class RichChatViewModel {
|
|||||||
/// `ChatViewModel.sendPrompt` and needs the body + model override.
|
/// `ChatViewModel.sendPrompt` and needs the body + model override.
|
||||||
public private(set) var projectScopedCommands: [ProjectSlashCommand] = []
|
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 >
|
/// Merged slash-menu list. Precedence: **ACP > project-scoped >
|
||||||
/// quick_commands** (most specific source wins). De-duplicated by name.
|
/// 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] {
|
public var availableCommands: [HermesSlashCommand] {
|
||||||
let acpNames = Set(acpCommands.map(\.name))
|
let acpNames = Set(acpCommands.map(\.name))
|
||||||
let projectAsHermes: [HermesSlashCommand] = projectScopedCommands
|
let projectAsHermes: [HermesSlashCommand] = projectScopedCommands
|
||||||
@@ -215,7 +238,28 @@ public final class RichChatViewModel {
|
|||||||
let quicks = quickCommands.filter {
|
let quicks = quickCommands.filter {
|
||||||
!acpNames.contains($0.name) && !projectNames.contains($0.name)
|
!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.
|
/// Look up the full project-scoped command payload by slash trigger.
|
||||||
|
|||||||
@@ -47,6 +47,9 @@ struct ChatView: View {
|
|||||||
projectContextBar
|
projectContextBar
|
||||||
messageList
|
messageList
|
||||||
Divider()
|
Divider()
|
||||||
|
if let hint = controller.vm.transientHint {
|
||||||
|
steeringToast(hint)
|
||||||
|
}
|
||||||
composer
|
composer
|
||||||
}
|
}
|
||||||
.navigationTitle("Chat")
|
.navigationTitle("Chat")
|
||||||
@@ -242,6 +245,26 @@ struct ChatView: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
|
/// Soft pill above the composer confirming a non-interruptive
|
||||||
|
/// command was received (e.g. `/steer`). Auto-clears via the
|
||||||
|
/// 4-second Task in `ChatController.send()`.
|
||||||
|
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)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.background(.tint.opacity(0.12))
|
||||||
|
.transition(.opacity)
|
||||||
|
}
|
||||||
|
|
||||||
private var composer: some View {
|
private var composer: some View {
|
||||||
HStack(alignment: .bottom, spacing: 8) {
|
HStack(alignment: .bottom, spacing: 8) {
|
||||||
TextField(
|
TextField(
|
||||||
@@ -557,6 +580,19 @@ final class ChatController {
|
|||||||
guard !sessionId.isEmpty else { return }
|
guard !sessionId.isEmpty else { return }
|
||||||
draft = ""
|
draft = ""
|
||||||
vm.addUserMessage(text: text)
|
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
|
// Project-scoped slash commands expand client-side: the user
|
||||||
// bubble shows the literal `/<name> args` they typed (above);
|
// bubble shows the literal `/<name> args` they typed (above);
|
||||||
// Hermes receives the expanded prompt template body. Other
|
// Hermes receives the expanded prompt template body. Other
|
||||||
|
|||||||
@@ -315,7 +315,23 @@ final class ChatViewModel {
|
|||||||
// and Hermes-version-independent. v2.5.
|
// and Hermes-version-independent. v2.5.
|
||||||
let wireText = expandIfProjectScoped(text)
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
acpStatus = "Agent working..."
|
acpStatus = "Agent working..."
|
||||||
|
}
|
||||||
acpPromptTask = Task { @MainActor in
|
acpPromptTask = Task { @MainActor in
|
||||||
do {
|
do {
|
||||||
let result = try await client.sendPrompt(sessionId: sessionId, text: wireText)
|
let result = try await client.sendPrompt(sessionId: sessionId, text: wireText)
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ struct RichChatView: View {
|
|||||||
)
|
)
|
||||||
|
|
||||||
Divider()
|
Divider()
|
||||||
|
if let hint = richChat.transientHint {
|
||||||
|
steeringToast(hint)
|
||||||
|
}
|
||||||
RichChatInputBar(
|
RichChatInputBar(
|
||||||
onSend: { text in
|
onSend: { text in
|
||||||
onSend(text)
|
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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user