From 7f5ff1946e433254c614d4b73d5c509d0f60c9e3 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 25 Apr 2026 08:45:25 +0200 Subject: [PATCH] feat(slash-commands): ScarfGo read-only browser sheet (Phase 1.7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read-only surface in iOS for browsing project-scoped slash commands. Editing on phones is its own UX problem (multi-line markdown + keyboard ergonomics) — Mac stays the canonical authoring surface in v2.5; iOS browses + invokes. When a project chat has at least one slash command loaded, projectContextBar grows a tinted " slash" chip on the right side. Tapping opens ProjectSlashCommandsBrowser: - List of every command with /, description, argument hint, optional model-override badge. - Tap a row → CommandDetailSheet with the full prompt-template body rendered in a monospaced block (text-selection enabled), plus metadata rows for argumentHint / model / tags. - Footer points authors back to Mac for editing. Verified: iOS build succeeds. Co-Authored-By: Claude Opus 4.7 (1M context) --- scarf/Scarf iOS/Chat/ChatView.swift | 24 +++ .../Chat/ProjectSlashCommandsBrowser.swift | 157 ++++++++++++++++++ 2 files changed, 181 insertions(+) create mode 100644 scarf/Scarf iOS/Chat/ProjectSlashCommandsBrowser.swift diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index f197b18..bd22358 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -25,6 +25,7 @@ struct ChatView: View { @Environment(\.serverContext) private var envContext @State private var controller: ChatController @State private var showProjectPicker = false + @State private var showSlashCommandsSheet = false init(config: IOSServerConfig, key: SSHKeyBundle) { self.config = config @@ -365,11 +366,34 @@ struct ChatView: View { .truncationMode(.tail) } Spacer() + if !controller.vm.projectScopedCommands.isEmpty { + Button { + showSlashCommandsSheet = true + } label: { + Label( + "\(controller.vm.projectScopedCommands.count) slash", + systemImage: "slash.circle.fill" + ) + .font(.caption) + .foregroundStyle(.tint) + .labelStyle(.titleAndIcon) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.tint.opacity(0.18), in: Capsule()) + } + .buttonStyle(.plain) + } } .padding(.horizontal, 12) .padding(.vertical, 6) .frame(maxWidth: .infinity, alignment: .leading) .background(.tint.opacity(0.1)) + .sheet(isPresented: $showSlashCommandsSheet) { + ProjectSlashCommandsBrowser( + projectName: projectName, + commands: controller.vm.projectScopedCommands + ) + } } } diff --git a/scarf/Scarf iOS/Chat/ProjectSlashCommandsBrowser.swift b/scarf/Scarf iOS/Chat/ProjectSlashCommandsBrowser.swift new file mode 100644 index 0000000..42b9e5c --- /dev/null +++ b/scarf/Scarf iOS/Chat/ProjectSlashCommandsBrowser.swift @@ -0,0 +1,157 @@ +import SwiftUI +import ScarfCore + +/// Read-only sheet that lists every project-scoped slash command +/// available in the current project chat. Surfaced from the chat +/// project-context bar via the `slash.circle.fill` chip when the +/// project has at least one command. +/// +/// **Read-only on purpose.** Authoring multi-line markdown bodies on +/// an iPhone keyboard is its own UX problem — Mac is the canonical +/// editor in v2.5. iOS users browse, tap-to-insert into the composer +/// (returning to the chat view), and let the slash menu drive +/// invocation from there. +struct ProjectSlashCommandsBrowser: View { + let projectName: String + let commands: [ProjectSlashCommand] + + @Environment(\.dismiss) private var dismiss + @State private var selectedCommand: ProjectSlashCommand? + + var body: some View { + NavigationStack { + List { + Section { + ForEach(commands) { cmd in + Button { + selectedCommand = cmd + } label: { + CommandRow(command: cmd) + } + .buttonStyle(.plain) + } + } footer: { + Text("Edit these in Scarf on macOS — they live at `/.scarf/slash-commands/.md` and ship with `.scarftemplate` bundles.") + .font(.caption) + } + } + .navigationTitle(projectName) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + .sheet(item: $selectedCommand) { cmd in + CommandDetailSheet(command: cmd) + .presentationDetents([.medium, .large]) + } + } + } +} + +private struct CommandRow: View { + let command: ProjectSlashCommand + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Text("/\(command.name)") + .font(.body.monospaced().weight(.medium)) + .foregroundStyle(.tint) + if let hint = command.argumentHint, !hint.isEmpty { + Text(hint) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + } + Spacer() + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundStyle(.secondary) + } + Text(command.description) + .font(.callout) + .foregroundStyle(.secondary) + .lineLimit(2) + 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()) + } + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + } +} + +/// Detail view for a single command — shows the prompt template body +/// so users can preview what Hermes will receive. +private struct CommandDetailSheet: View { + let command: ProjectSlashCommand + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("/\(command.name)") + .font(.title2.monospaced().weight(.medium)) + .foregroundStyle(.tint) + Text(command.description) + .font(.body) + .foregroundStyle(.primary) + } + + if let hint = command.argumentHint, !hint.isEmpty { + metadataRow(label: "Argument hint", value: hint, mono: true) + } + if let model = command.model, !model.isEmpty { + metadataRow(label: "Model override", value: model, mono: true) + } + if let tags = command.tags, !tags.isEmpty { + metadataRow(label: "Tags", value: tags.joined(separator: ", "), mono: false) + } + + Divider() + + Text("Prompt template") + .font(.headline) + Text(command.body) + .font(.system(.callout, design: .monospaced)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(10) + .background(.background.secondary, in: RoundedRectangle(cornerRadius: 8)) + .textSelection(.enabled) + Text("`{{argument}}` is replaced with whatever you type after `/\(command.name)`. The agent receives the expanded body — never the literal slash.") + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + .padding() + } + .navigationTitle("/\(command.name)") + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button("Done") { dismiss() } + } + } + } + } + + private func metadataRow(label: String, value: String, mono: Bool) -> some View { + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + Text(value) + .font(mono ? .system(.body, design: .monospaced) : .body) + } + } +}