diff --git a/scarf/scarf/Features/Projects/ViewModels/ProjectSlashCommandsViewModel.swift b/scarf/scarf/Features/Projects/ViewModels/ProjectSlashCommandsViewModel.swift new file mode 100644 index 0000000..f388eda --- /dev/null +++ b/scarf/scarf/Features/Projects/ViewModels/ProjectSlashCommandsViewModel.swift @@ -0,0 +1,223 @@ +import Foundation +import ScarfCore +import os + +/// Drives the per-project Slash Commands tab on Mac. Loads commands from +/// `/.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 = 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 = 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 + } +} diff --git a/scarf/scarf/Features/Projects/Views/ProjectSlashCommandsView.swift b/scarf/scarf/Features/Projects/Views/ProjectSlashCommandsView.swift new file mode 100644 index 0000000..af38a59 --- /dev/null +++ b/scarf/scarf/Features/Projects/Views/ProjectSlashCommandsView.swift @@ -0,0 +1,332 @@ +import SwiftUI +import ScarfCore + +/// The "Slash Commands" tab on the per-project surface. Lists the +/// project-scoped commands stored at `/.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("`/` shortcuts that expand into prompt templates. Stored at `/.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 `/ ` in the menu — e.g. ``.") + 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 `` marker.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + } +} diff --git a/scarf/scarf/Features/Projects/Views/ProjectsView.swift b/scarf/scarf/Features/Projects/Views/ProjectsView.swift index b9ff75e..bdde1e3 100644 --- a/scarf/scarf/Features/Projects/Views/ProjectsView.swift +++ b/scarf/scarf/Features/Projects/Views/ProjectsView.swift @@ -6,12 +6,14 @@ private enum DashboardTab: String, CaseIterable { case dashboard = "Dashboard" case site = "Site" case sessions = "Sessions" + case slashCommands = "Slash" var displayName: LocalizedStringResource { switch self { case .dashboard: return "Dashboard" case .site: return "Site" 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 .site: return "globe" case .sessions: return "bubble.left.and.bubble.right" + case .slashCommands: return "slash.circle" } } } @@ -366,6 +369,12 @@ struct ProjectsView: View { } else { 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