mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 10:36:35 +00:00
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:
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user