feat(slash-commands): Mac authoring UI — Slash Commands tab + editor (Phase 1.6)

Adds a fourth per-project tab on Mac (alongside Dashboard / Site /
Sessions) for managing project-scoped slash commands. The whole
authoring story lives here: list, add, edit, duplicate, delete, with
a live-preview pane that expands {{argument}} substitutions against a
sample-arg field so authors see exactly what Hermes will receive.

- ProjectSlashCommandsViewModel — @Observable @MainActor, owns the
  commands list + editor draft + dirty-tracking. Routes through
  ScarfCore's ProjectSlashCommandService for all I/O. Save validates
  name shape + collision detection before writing; rename cleans up
  the previous file.
- ProjectSlashCommandsView — list with content menu (Edit/Duplicate/
  Delete), empty state with CTA, error banner for transient failures.
- SlashCommandEditorSheet — HSplitView with form on the left
  (identity / optional / monospaced body editor) and live preview on
  the right (sample-argument field + expanded prompt). Save disabled
  until name + description + body are non-empty.
- DashboardTab gains .slashCommands case alongside dashboard / site /
  sessions; visibleTabs filter unchanged so it always shows for any
  selected project.

iOS gets a read-only browser in the next commit (Phase 1.7) — phone
keyboards aren't great for multi-line markdown editing.

Verified: Mac build succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 08:43:49 +02:00
parent 8a87ff1922
commit 9164e65cac
3 changed files with 564 additions and 0 deletions
@@ -0,0 +1,223 @@
import Foundation
import ScarfCore
import os
/// Drives the per-project Slash Commands tab on Mac. Loads commands from
/// `<project>/.scarf/slash-commands/` via `ProjectSlashCommandService`,
/// supports save / delete / duplicate, and surfaces an inline error
/// banner on failures (rather than silent log).
///
/// Pure UI shell the actual on-disk shape + parser + frontmatter
/// validation lives in ScarfCore's `ProjectSlashCommandService`. This
/// view-model just owns the editor's draft state + dirty tracking.
@MainActor
@Observable
final class ProjectSlashCommandsViewModel {
private static let logger = Logger(
subsystem: "com.scarf",
category: "ProjectSlashCommandsViewModel"
)
let project: ProjectEntry
private let service: ProjectSlashCommandService
// MARK: - List state
private(set) var commands: [ProjectSlashCommand] = []
private(set) var isLoading: Bool = true
var lastError: String?
// MARK: - Editor state
/// Non-nil when the editor sheet is open. The view binds to this
/// to drive `.sheet(item:)`. Created via `beginNew()` or
/// `beginEdit(_:)`; cleared on close (cancel) or save.
var draft: Draft?
init(project: ProjectEntry, context: ServerContext = .local) {
self.project = project
self.service = ProjectSlashCommandService(context: context)
}
// MARK: - List actions
func load() async {
isLoading = true
let svc = service
let proj = project.path
commands = await Task.detached {
svc.loadCommands(at: proj)
}.value
isLoading = false
}
/// Open the editor sheet for a brand-new command. Pre-fills sensible
/// defaults so the form is immediately usable.
func beginNew() {
draft = Draft(
isNew: true,
originalName: nil,
name: "",
description: "",
argumentHint: "",
model: "",
tags: "",
body: "Describe what the agent should do.\n\nUser argument: {{argument | default: \"none\"}}.\n"
)
}
/// Open the editor sheet pre-populated with an existing command's
/// fields. The original name is captured so a rename can clean up
/// the previous file on save.
func beginEdit(_ command: ProjectSlashCommand) {
draft = Draft(
isNew: false,
originalName: command.name,
name: command.name,
description: command.description,
argumentHint: command.argumentHint ?? "",
model: command.model ?? "",
tags: (command.tags ?? []).joined(separator: ", "),
body: command.body
)
}
/// Open the editor for a fresh copy of an existing command. The name
/// is suffixed with `-copy` so the user has a starting point that
/// won't collide.
func beginDuplicate(of command: ProjectSlashCommand) {
draft = Draft(
isNew: true,
originalName: nil,
name: "\(command.name)-copy",
description: command.description,
argumentHint: command.argumentHint ?? "",
model: command.model ?? "",
tags: (command.tags ?? []).joined(separator: ", "),
body: command.body
)
}
func cancelEdit() {
draft = nil
}
/// Validate the draft, persist via the service, and reload the list.
/// Returns true on success so the sheet can dismiss; populates
/// `lastError` on failure (the sheet stays open so the user can fix).
@discardableResult
func saveDraft() async -> Bool {
guard let d = draft else { return false }
if let reason = ProjectSlashCommand.validateName(d.name) {
lastError = reason
return false
}
// On rename, prevent overwriting an unrelated existing command.
if let originalName = d.originalName, originalName != d.name,
commands.contains(where: { $0.name == d.name }) {
lastError = "A command named \"\(d.name)\" already exists. Pick a different name or delete the old one first."
return false
}
if d.isNew, commands.contains(where: { $0.name == d.name }) {
lastError = "A command named \"\(d.name)\" already exists in this project."
return false
}
let cmd = ProjectSlashCommand(
name: d.name,
description: d.description.trimmingCharacters(in: .whitespacesAndNewlines),
argumentHint: d.argumentHint.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : d.argumentHint,
model: d.model.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? nil : d.model,
tags: parseTags(d.tags),
body: d.body,
sourcePath: "" // service ignores this on save
)
let svc = service
let proj = project.path
let originalName = d.originalName
let result: Result<Void, Error> = await Task.detached {
do {
try svc.save(cmd, at: proj)
if let originalName, originalName != cmd.name {
try svc.delete(named: originalName, at: proj)
}
return .success(())
} catch {
return .failure(error)
}
}.value
switch result {
case .success:
lastError = nil
draft = nil
await load()
return true
case .failure(let error):
Self.logger.error("save failed for \(cmd.name, privacy: .public): \(error.localizedDescription, privacy: .public)")
lastError = "Couldn't save: \(error.localizedDescription)"
return false
}
}
func delete(_ command: ProjectSlashCommand) async {
let svc = service
let proj = project.path
let name = command.name
let result: Result<Void, Error> = await Task.detached {
do {
try svc.delete(named: name, at: proj)
return .success(())
} catch {
return .failure(error)
}
}.value
if case .failure(let error) = result {
Self.logger.error("delete failed for \(name, privacy: .public): \(error.localizedDescription, privacy: .public)")
lastError = "Couldn't delete \"\(name)\": \(error.localizedDescription)"
return
}
lastError = nil
await load()
}
/// Render a preview of the draft body with `{{argument}}` substituted
/// against a user-supplied sample argument. Used by the editor's
/// preview pane.
func previewExpansion(forArgument argument: String) -> String {
guard let d = draft else { return "" }
let cmd = ProjectSlashCommand(
name: d.name.isEmpty ? "preview" : d.name,
description: d.description,
body: d.body,
sourcePath: ""
)
return service.expand(cmd, withArgument: argument)
}
// MARK: - Draft model
/// Editor draft state. Pure value bound to TextField inputs so
/// changes flow through SwiftUI state. `Identifiable` via name so
/// `.sheet(item:)` can present it.
struct Draft: Identifiable {
var id: String { isNew ? "draft-new" : (originalName ?? "draft-edit") }
let isNew: Bool
/// Filename before the user typed in the editor (used to
/// detect renames). Nil on `.beginNew`.
let originalName: String?
var name: String
var description: String
var argumentHint: String
var model: String
/// Comma-separated user-typed tag list. Parsed on save.
var tags: String
var body: String
}
// MARK: - Helpers
private func parseTags(_ raw: String) -> [String]? {
let parts = raw.split(separator: ",").map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
let cleaned = parts.filter { !$0.isEmpty }
return cleaned.isEmpty ? nil : cleaned
}
}
@@ -0,0 +1,332 @@
import SwiftUI
import ScarfCore
/// The "Slash Commands" tab on the per-project surface. Lists the
/// project-scoped commands stored at `<project>/.scarf/slash-commands/`
/// and provides authoring affordances (add, edit, duplicate, delete).
///
/// Project-scoped commands are a Scarf-side primitive added in v2.5
/// they ship in `.scarftemplate` bundles and are intercepted by the chat
/// view models for client-side prompt expansion (see
/// `ProjectSlashCommandService.expand(_:withArgument:)`). The agent never
/// sees the slash; it sees the expanded prompt.
struct ProjectSlashCommandsView: View {
let project: ProjectEntry
@Environment(\.serverContext) private var serverContext
@State private var viewModel: ProjectSlashCommandsViewModel
init(project: ProjectEntry) {
self.project = project
_viewModel = State(initialValue: ProjectSlashCommandsViewModel(project: project))
}
var body: some View {
VStack(alignment: .leading, spacing: 0) {
header
Divider()
content
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
// Rerun on project change so switching the sidebar selection
// rebuilds the VM under the new path. Re-inits with the host's
// serverContext so remote projects read over SSH.
.task(id: project.id) {
viewModel = ProjectSlashCommandsViewModel(project: project, context: serverContext)
await viewModel.load()
}
.sheet(item: Binding(
get: { viewModel.draft },
set: { newValue in
if newValue == nil { viewModel.cancelEdit() }
}
)) { _ in
SlashCommandEditorSheet(viewModel: viewModel)
.frame(minWidth: 720, minHeight: 560)
}
}
// MARK: - Header
private var header: some View {
HStack {
VStack(alignment: .leading, spacing: 2) {
Text("Slash Commands")
.font(.headline)
Text("`/<name>` shortcuts that expand into prompt templates. Stored at `<project>/.scarf/slash-commands/` so they ship with `.scarftemplate` bundles.")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
}
Spacer()
Button {
viewModel.beginNew()
} label: {
Label("Add Command", systemImage: "plus.circle.fill")
}
.controlSize(.regular)
}
.padding()
}
// MARK: - Content
@ViewBuilder
private var content: some View {
if viewModel.isLoading {
ProgressView("Loading commands…")
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if viewModel.commands.isEmpty {
ContentUnavailableView {
Label("No slash commands yet", systemImage: "slash.circle")
} description: {
Text("Add reusable prompt templates here. Each command shows up in the chat slash menu when you're chatting in this project.")
} actions: {
Button("Add Command") { viewModel.beginNew() }
.buttonStyle(.borderedProminent)
}
} else {
VStack(spacing: 0) {
if let err = viewModel.lastError {
errorBanner(err)
}
List {
ForEach(viewModel.commands) { cmd in
CommandRow(command: cmd)
.contextMenu {
Button("Edit…") { viewModel.beginEdit(cmd) }
Button("Duplicate") { viewModel.beginDuplicate(of: cmd) }
Divider()
Button("Delete…", role: .destructive) {
Task { await viewModel.delete(cmd) }
}
}
}
}
.listStyle(.inset)
}
}
}
private func errorBanner(_ message: String) -> some View {
HStack(alignment: .top, spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
VStack(alignment: .leading, spacing: 2) {
Text("Couldn't update slash commands")
.font(.subheadline.weight(.semibold))
Text(message)
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
Button("Dismiss") { viewModel.lastError = nil }
.controlSize(.small)
.buttonStyle(.bordered)
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(Color.orange.opacity(0.08))
}
}
// MARK: - Row
private struct CommandRow: View {
let command: ProjectSlashCommand
var body: some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "slash.circle.fill")
.font(.title3)
.foregroundStyle(.tint)
.frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
HStack(spacing: 6) {
Text("/\(command.name)")
.font(.body.monospaced().weight(.medium))
if let hint = command.argumentHint, !hint.isEmpty {
Text(hint)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.secondary.opacity(0.12), in: Capsule())
}
if let model = command.model, !model.isEmpty {
Label(model, systemImage: "cpu")
.font(.caption2)
.foregroundStyle(.tint)
.labelStyle(.titleAndIcon)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.tint.opacity(0.12), in: Capsule())
}
}
Text(command.description)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(2)
if let tags = command.tags, !tags.isEmpty {
HStack(spacing: 4) {
ForEach(tags, id: \.self) { tag in
Text(tag)
.font(.caption2)
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.secondary.opacity(0.1), in: Capsule())
}
}
}
}
Spacer()
}
.padding(.vertical, 4)
}
}
// MARK: - Editor sheet
/// Modal editor for a single slash command. Form on the left, live
/// preview pane on the right showing the expanded prompt with a
/// sample-argument field so the author can see what the agent will
/// actually receive.
struct SlashCommandEditorSheet: View {
@Bindable var viewModel: ProjectSlashCommandsViewModel
@State private var sampleArgument: String = ""
var body: some View {
VStack(spacing: 0) {
HStack {
Text(viewModel.draft?.isNew == true ? "Add Slash Command" : "Edit Slash Command")
.font(.title3.weight(.semibold))
Spacer()
Button("Cancel") { viewModel.cancelEdit() }
.keyboardShortcut(.cancelAction)
Button("Save") {
Task { await viewModel.saveDraft() }
}
.buttonStyle(.borderedProminent)
.keyboardShortcut(.defaultAction)
.disabled(saveDisabled)
}
.padding()
Divider()
HSplitView {
form
.frame(minWidth: 360, idealWidth: 380)
preview
.frame(minWidth: 320)
}
}
}
private var saveDisabled: Bool {
guard let d = viewModel.draft else { return true }
return d.name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| d.description.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|| d.body.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
}
@ViewBuilder
private var form: some View {
if let _ = viewModel.draft {
Form {
Section("Identity") {
TextField("Name", text: Binding(
get: { viewModel.draft?.name ?? "" },
set: { viewModel.draft?.name = $0 }
))
.textFieldStyle(.roundedBorder)
.help("Lowercase letters, digits, and hyphens. Must start with a letter.")
if let nameError = nameValidationMessage {
Text(nameError)
.font(.caption)
.foregroundStyle(.orange)
}
TextField("Description", text: Binding(
get: { viewModel.draft?.description ?? "" },
set: { viewModel.draft?.description = $0 }
))
.textFieldStyle(.roundedBorder)
.help("Shown as the subtitle in the chat slash menu.")
}
Section("Optional") {
TextField("Argument hint", text: Binding(
get: { viewModel.draft?.argumentHint ?? "" },
set: { viewModel.draft?.argumentHint = $0 }
))
.textFieldStyle(.roundedBorder)
.help("Placeholder shown after `/<name> ` in the menu — e.g. `<focus area>`.")
TextField("Model override", text: Binding(
get: { viewModel.draft?.model ?? "" },
set: { viewModel.draft?.model = $0 }
))
.textFieldStyle(.roundedBorder)
.help("Optional. Sets the LLM model for this turn.")
TextField("Tags (comma-separated)", text: Binding(
get: { viewModel.draft?.tags ?? "" },
set: { viewModel.draft?.tags = $0 }
))
.textFieldStyle(.roundedBorder)
}
Section("Prompt template") {
Text("Use `{{argument}}` to substitute the user's input. `{{argument | default: \"\"}}` provides a fallback when the user invokes the command without arguments.")
.font(.caption)
.foregroundStyle(.secondary)
TextEditor(text: Binding(
get: { viewModel.draft?.body ?? "" },
set: { viewModel.draft?.body = $0 }
))
.font(.system(.body, design: .monospaced))
.frame(minHeight: 220)
.border(Color.secondary.opacity(0.3))
}
}
.formStyle(.grouped)
.padding(.bottom)
} else {
ProgressView()
}
}
private var nameValidationMessage: String? {
guard let name = viewModel.draft?.name, !name.isEmpty else { return nil }
return ProjectSlashCommand.validateName(name)
}
@ViewBuilder
private var preview: some View {
VStack(alignment: .leading, spacing: 8) {
HStack {
Text("Preview")
.font(.headline)
Spacer()
}
HStack {
Text("Sample argument")
.font(.caption)
.foregroundStyle(.secondary)
TextField("(empty)", text: $sampleArgument)
.textFieldStyle(.roundedBorder)
}
ScrollView {
Text(viewModel.previewExpansion(forArgument: sampleArgument))
.font(.system(.body, design: .monospaced))
.frame(maxWidth: .infinity, alignment: .leading)
.padding(8)
.background(.background.secondary, in: RoundedRectangle(cornerRadius: 6))
.textSelection(.enabled)
}
Text("This is the prompt Hermes will receive. The user sees the literal `/\(viewModel.draft?.name ?? "name")` they typed in their own bubble; the expanded body goes to the agent with a `<!-- scarf-slash:<name> -->` marker.")
.font(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding()
}
}
@@ -6,12 +6,14 @@ private enum DashboardTab: String, CaseIterable {
case dashboard = "Dashboard" case dashboard = "Dashboard"
case site = "Site" case site = "Site"
case sessions = "Sessions" case sessions = "Sessions"
case slashCommands = "Slash"
var displayName: LocalizedStringResource { var displayName: LocalizedStringResource {
switch self { switch self {
case .dashboard: return "Dashboard" case .dashboard: return "Dashboard"
case .site: return "Site" case .site: return "Site"
case .sessions: return "Sessions" case .sessions: return "Sessions"
case .slashCommands: return "Slash Commands"
} }
} }
@@ -20,6 +22,7 @@ private enum DashboardTab: String, CaseIterable {
case .dashboard: return "square.grid.2x2" case .dashboard: return "square.grid.2x2"
case .site: return "globe" case .site: return "globe"
case .sessions: return "bubble.left.and.bubble.right" case .sessions: return "bubble.left.and.bubble.right"
case .slashCommands: return "slash.circle"
} }
} }
} }
@@ -366,6 +369,12 @@ struct ProjectsView: View {
} else { } else {
ContentUnavailableView("No project selected", systemImage: "bubble.left.and.bubble.right") ContentUnavailableView("No project selected", systemImage: "bubble.left.and.bubble.right")
} }
case .slashCommands:
if let project = viewModel.selectedProject {
ProjectSlashCommandsView(project: project)
} else {
ContentUnavailableView("No project selected", systemImage: "slash.circle")
}
} }
} }
// Clamp the container VStack to the detail column's // Clamp the container VStack to the detail column's