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
+36
View File
@@ -47,6 +47,9 @@ struct ChatView: View {
projectContextBar
messageList
Divider()
if let hint = controller.vm.transientHint {
steeringToast(hint)
}
composer
}
.navigationTitle("Chat")
@@ -242,6 +245,26 @@ struct ChatView: View {
}
@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 {
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 `/<name> args` they typed (above);
// Hermes receives the expanded prompt template body. Other