Files
scarf/scarf/scarf/Features/Projects/Views/ProjectSlashCommandsView.swift
T
Alan Wizemann 23dd8becb9 polish: tokenize remaining sheets, page headers, and widgets
Phase 1 — Page headers for the 9 non-mockup feature views: Skills,
Gateway, Platforms, Personalities, QuickCommands, CredentialPools,
Plugins, Webhooks, Profiles. Each now ships a ScarfPageHeader with
title + subtitle + tokenized trailing actions (ScarfPrimary /
Secondary / Ghost buttons), wrapped in .fixedSize so labels can't
wrap at narrow widths. Outer .background(ScarfColor.backgroundPrimary).

Phase 2 — Modal sheets: ModelPickerSheet, NousSignInSheet,
RenameProjectSheet, MoveToFolderSheet, the five Templates sheets
(TemplateInstall / TemplateConfig / TemplateExport / TemplateUninstall
/ ConfigEditorSheet), three MCPServer sheets (AddCustom / Editor /
PresetPicker), AddServerSheet, ManageServersView, MissingServerView.
.font(.headline) -> .scarfStyle(.headline);
.buttonStyle(.borderedProminent) -> ScarfPrimaryButton(); raw text
fields where touched -> ScarfTextField; cancel buttons -> ScarfGhostButton.

Phase 3 — All 12 platform setup views (Discord / Email / Feishu /
HomeAssistant / IMessage / Matrix / Mattermost / Signal / Slack /
Telegram / Webhook / WhatsApp). Connect buttons swapped to
ScarfPrimaryButton.

Phase 4 — All 7 project dashboard widgets (Chart / List / Progress /
Stat / Table / Text / Webview). .font(.caption) -> .scarfStyle(.caption);
.background(.quaternary.opacity(0.5)) -> ScarfColor.backgroundSecondary;
RoundedRectangle(cornerRadius: 8) -> ScarfRadius.lg.

Phase 5 — Project sub-views: ProjectSessionsView, ProjectsSidebar,
ProjectSlashCommandsView. Same token sweep.

Phase 6 — Common chrome:
- LoadingOverlay: .font(.callout/caption) -> .scarfStyle; secondary
  foreground -> ScarfColor.foregroundMuted; window-background ->
  ScarfColor.backgroundPrimary.
- ServerSwitcherToolbar: status dot + label tokenized.
- ConnectionStatusPill: status colors -> ScarfColor.success/warning/
  danger; error sheet header -> ScarfPrimaryButton retry.

Build green on both Mac (scarf) and iOS (scarf mobile) schemes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 14:39:13 +02:00

335 lines
13 KiB
Swift

import SwiftUI
import ScarfCore
import ScarfDesign
/// 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")
.scarfStyle(.headline)
Text("`/<name>` shortcuts that expand into prompt templates. Stored at `<project>/.scarf/slash-commands/` so they ship with `.scarftemplate` bundles.")
.scarfStyle(.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(ScarfPrimaryButton())
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
} 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)
.scarfStyle(.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)
.scarfStyle(.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(ScarfPrimaryButton())
.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)
.scarfStyle(.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.")
.scarfStyle(.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")
.scarfStyle(.headline)
Spacer()
}
HStack {
Text("Sample argument")
.scarfStyle(.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.")
.scarfStyle(.caption)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
}
.padding()
}
}