mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 18:44:45 +00:00
feat(slash-commands): ScarfGo read-only browser sheet (Phase 1.7)
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 "<N> slash" chip on the right side. Tapping opens ProjectSlashCommandsBrowser: - List of every command with /<name>, 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) <noreply@anthropic.com>
This commit is contained in:
@@ -25,6 +25,7 @@ struct ChatView: View {
|
|||||||
@Environment(\.serverContext) private var envContext
|
@Environment(\.serverContext) private var envContext
|
||||||
@State private var controller: ChatController
|
@State private var controller: ChatController
|
||||||
@State private var showProjectPicker = false
|
@State private var showProjectPicker = false
|
||||||
|
@State private var showSlashCommandsSheet = false
|
||||||
|
|
||||||
init(config: IOSServerConfig, key: SSHKeyBundle) {
|
init(config: IOSServerConfig, key: SSHKeyBundle) {
|
||||||
self.config = config
|
self.config = config
|
||||||
@@ -365,11 +366,34 @@ struct ChatView: View {
|
|||||||
.truncationMode(.tail)
|
.truncationMode(.tail)
|
||||||
}
|
}
|
||||||
Spacer()
|
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(.horizontal, 12)
|
||||||
.padding(.vertical, 6)
|
.padding(.vertical, 6)
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.background(.tint.opacity(0.1))
|
.background(.tint.opacity(0.1))
|
||||||
|
.sheet(isPresented: $showSlashCommandsSheet) {
|
||||||
|
ProjectSlashCommandsBrowser(
|
||||||
|
projectName: projectName,
|
||||||
|
commands: controller.vm.projectScopedCommands
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 `<project>/.scarf/slash-commands/<name>.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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user