From 6808adfa98dd65706cdcbd62eb2c70a0ea9f5657 Mon Sep 17 00:00:00 2001 From: Alan Wizemann Date: Sat, 25 Apr 2026 08:35:30 +0200 Subject: [PATCH] feat(slash-commands): portable project-scoped slash commands (Phase 1.1-1.4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Net-new Scarf primitive — Hermes has no project-scoped slash command concept. Commands live at /.scarf/slash-commands/.md as Markdown files with YAML frontmatter; Scarf intercepts the chat slash menu, expands {{argument}} substitution client-side, and sends the expanded prompt as a normal user message. Works uniformly on Mac + iOS, local + remote SSH, against any Hermes version (no upstream dep). Lands the model + service + chat wiring; editor UI (Mac), read-only browser (iOS), AGENTS.md block extension, .scarftemplate format extension, and tests follow in subsequent commits. What this commit ships: - ScarfCore Models/ProjectSlashCommand.swift — Sendable struct carrying name + description + argumentHint? + model? + tags? + body + sourcePath. Validates name shape (lowercase, hyphens, starts with letter, ≤64 chars). - ScarfCore Services/ProjectSlashCommandService.swift — transport- based loadCommands(at:), loadCommand(at:), save(_:at:), delete(named:at:), expand(_:withArgument:). Markdown-with- frontmatter parser reuses HermesYAML so no new dep. Substitution supports `{{argument}}` and `{{argument | default: "..."}}`. - HermesSlashCommand.Source gains .projectScoped (full payload looked up in RichChatViewModel by name) and .acpNonInterruptive (reserved for /steer in Phase 2.1). - RichChatViewModel.projectScopedCommands + projectScopedCommand(named:) + loadProjectScopedCommands(at:); availableCommands precedence is ACP > project-scoped > quick_commands, all de-duped by name. - Mac ChatViewModel: expandIfProjectScoped(_:) helper called in sendViaACP; loads commands when currentProjectPath is set in startACPSession's resolution branch. - iOS ChatController: same pattern in send(); loads commands in both resetAndStartInProject and startResuming(sessionID:); resume now resolves both path AND name so we can read the slash-commands dir. Verified: ScarfCore + Mac + iOS all build clean. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ScarfCore/Models/HermesSlashCommand.swift | 23 +- .../Models/ProjectSlashCommand.swift | 89 +++++ .../Services/ProjectSlashCommandService.swift | 316 ++++++++++++++++++ .../ViewModels/RichChatViewModel.swift | 56 +++- scarf/Scarf iOS/Chat/ChatView.swift | 47 ++- .../Chat/ViewModels/ChatViewModel.swift | 44 ++- 6 files changed, 565 insertions(+), 10 deletions(-) create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ProjectSlashCommand.swift create mode 100644 scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectSlashCommandService.swift diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift index bb850d7..2f2bd74 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/HermesSlashCommand.swift @@ -1,12 +1,29 @@ import Foundation -/// A slash command available in chat. Sourced either from the ACP server -/// (`available_commands_update`) or from user-defined `quick_commands` in -/// `config.yaml`. +/// A slash command available in chat. Sourced from one of four places — +/// see `Source` for which. public struct HermesSlashCommand: Identifiable, Sendable, Equatable { + /// Where this command came from. Drives the slash-menu badge and the + /// chat view model's invocation path (literal-send vs client-side + /// expansion vs non-interruptive flag). public enum Source: Sendable, Equatable { + /// Advertised by the ACP server via `available_commands_update`. + /// Sent to the agent as the literal slash text. case acp + /// User-defined `quick_commands.` in `~/.hermes/config.yaml` + /// (legacy). Sent to the agent as the literal slash text. case quickCommand + /// Project-scoped, Scarf-managed command at + /// `/.scarf/slash-commands/.md`. Scarf intercepts + /// the invocation, expands `{{argument}}` substitution against the + /// command's body, and sends the result as a normal user prompt + /// (the agent never sees the slash trigger). Added in v2.5. + case projectScoped + /// ACP-native commands that don't interrupt the current turn — + /// `/steer` is the flagship case. The chat UI keeps the + /// "agent working" indicator on; the guidance applies after the + /// next tool call. Added in v2.5 alongside Hermes v2026.4.23. + case acpNonInterruptive } public var id: String { name } diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ProjectSlashCommand.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ProjectSlashCommand.swift new file mode 100644 index 0000000..12999bc --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Models/ProjectSlashCommand.swift @@ -0,0 +1,89 @@ +import Foundation + +/// A user-authored, project-scoped slash command. Lives at +/// `/.scarf/slash-commands/.md` as a Markdown file with +/// YAML frontmatter — Scarf-side primitive, not a Hermes feature. +/// +/// The command is invoked via the chat slash menu like any other command, +/// but Scarf intercepts the invocation client-side: the body is treated as +/// a prompt template (with `{{argument}}` substitution from whatever +/// followed the slash), expanded to a regular user prompt, and sent to +/// Hermes as a normal message. The agent never sees the slash trigger; +/// it sees the expanded prompt prefixed with a `` +/// marker so it can correlate. +/// +/// **Why client-side expansion.** Hermes has no project-scoped slash +/// command concept. Doing the substitution in Scarf means commands work +/// uniformly on Mac + iOS, local + remote SSH transports, against any +/// Hermes version (no upstream dependency). +public struct ProjectSlashCommand: Sendable, Equatable, Identifiable { + /// Stable identity is the command's `name` (must be unique within a + /// project's `slash-commands/` dir). + public var id: String { name } + + /// Slash trigger — drives the menu and the on-disk filename. + /// Must match `[a-z][a-z0-9-]*`. Validated by the service on save. + public let name: String + + /// Human-readable subtitle shown in the slash menu. + public let description: String + + /// Optional placeholder shown after `/ ` in the menu (e.g. ``). + public let argumentHint: String? + + /// Optional per-command model override. When set, the expanded prompt + /// is sent with this model in the ACP envelope, regardless of the + /// session's default. + public let model: String? + + /// Optional grouping tags for the catalog / editor UI. Not surfaced + /// to the agent. + public let tags: [String]? + + /// The prompt template body (everything after the YAML frontmatter + /// closer). Mustache-style `{{argument}}` substitution; supports + /// `{{argument | default: "..."}}` for fallbacks. + public let body: String + + /// Absolute path the command was loaded from (used by the editor's + /// save/delete affordances + by the uninstaller's lock-file tracking). + public let sourcePath: String + + public init( + name: String, + description: String, + argumentHint: String? = nil, + model: String? = nil, + tags: [String]? = nil, + body: String, + sourcePath: String + ) { + self.name = name + self.description = description + self.argumentHint = argumentHint + self.model = model + self.tags = tags + self.body = body + self.sourcePath = sourcePath + } +} + +// MARK: - Validation + +public extension ProjectSlashCommand { + /// Allowed name shape: lowercase, digits, hyphens; must start with a + /// letter. Mirrors the catalog validator's rule so on-disk files + /// authored in Scarf round-trip cleanly through `.scarftemplate`. + static let validNamePattern = #"^[a-z][a-z0-9-]*$"# + + /// Returns nil when the name is well-formed; otherwise a human-readable + /// reason suitable for inline editor UX. + static func validateName(_ name: String) -> String? { + if name.isEmpty { return "Name can't be empty." } + if name.count > 64 { return "Name must be 64 characters or fewer." } + if name.range(of: validNamePattern, options: .regularExpression) == nil { + return "Name must start with a letter and contain only lowercase letters, digits, and hyphens." + } + return nil + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectSlashCommandService.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectSlashCommandService.swift new file mode 100644 index 0000000..1386766 --- /dev/null +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/Services/ProjectSlashCommandService.swift @@ -0,0 +1,316 @@ +import Foundation +#if canImport(os) +import os +#endif + +/// Loads, saves, and expands user-authored project-scoped slash commands +/// stored at `/.scarf/slash-commands/.md`. +/// +/// Each command is a Markdown file with a YAML frontmatter block: +/// +/// ```markdown +/// --- +/// name: review +/// description: Code-review the current branch +/// argumentHint: +/// model: claude-sonnet-4.5 +/// tags: +/// - code-review +/// - git +/// --- +/// You are reviewing changes on the current git branch. … +/// Focus area: {{argument | default: "general code quality"}}. +/// ``` +/// +/// The service is transport-based — `Mac` reads the local filesystem, +/// `ScarfGo` reads over SFTP via Citadel — so the same code path works +/// on both platforms. Failures are logged but not thrown for `load*` +/// methods because the slash menu degrades gracefully (no commands = +/// menu just shows ACP + quick-command sources). +public struct ProjectSlashCommandService: Sendable { + #if canImport(os) + private static let logger = Logger( + subsystem: "com.scarf", + category: "ProjectSlashCommandService" + ) + #endif + + public let context: ServerContext + + public nonisolated init(context: ServerContext = .local) { + self.context = context + } + + // MARK: - Read + + /// List every slash command at `/.scarf/slash-commands/`. + /// Sorted by `name` ascending. Returns `[]` for projects that have no + /// `slash-commands/` directory yet — that's the default state for any + /// project that hasn't authored one. + public nonisolated func loadCommands(at projectPath: String) -> [ProjectSlashCommand] { + let dir = Self.slashCommandsDir(for: projectPath) + let transport = context.makeTransport() + guard transport.fileExists(dir) else { return [] } + + let entries: [String] + do { + entries = try transport.listDirectory(dir) + } catch { + #if canImport(os) + Self.logger.warning( + "listDirectory failed at \(dir, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty list" + ) + #endif + return [] + } + + var commands: [ProjectSlashCommand] = [] + for entry in entries where entry.hasSuffix(".md") { + let path = dir + "/" + entry + if let cmd = loadCommand(at: path) { + commands.append(cmd) + } + } + return commands.sorted { $0.name < $1.name } + } + + /// Load a single command file by absolute path. Returns nil on any + /// parse / IO failure (logged). + public nonisolated func loadCommand(at path: String) -> ProjectSlashCommand? { + let transport = context.makeTransport() + do { + let data = try transport.readFile(path) + guard let raw = String(data: data, encoding: .utf8) else { + #if canImport(os) + Self.logger.warning("non-UTF8 contents at \(path, privacy: .public)") + #endif + return nil + } + return Self.parse(raw, sourcePath: path) + } catch { + #if canImport(os) + Self.logger.warning( + "readFile failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)" + ) + #endif + return nil + } + } + + // MARK: - Write + + /// Persist the given command. Throws if the name is invalid or the + /// transport rejects the write. Creates `/.scarf/slash-commands/` + /// on demand. + public nonisolated func save( + _ command: ProjectSlashCommand, + at projectPath: String + ) throws { + if let reason = ProjectSlashCommand.validateName(command.name) { + throw ServiceError.invalidName(reason) + } + let dir = Self.slashCommandsDir(for: projectPath) + let transport = context.makeTransport() + if !transport.fileExists(dir) { + try transport.createDirectory(dir) + } + let path = dir + "/" + command.name + ".md" + let serialised = Self.serialise(command) + guard let data = serialised.data(using: .utf8) else { + throw ServiceError.encodingFailed + } + try transport.writeFile(path, data: data) + } + + /// Remove the command with the given name. No-op if it doesn't exist. + public nonisolated func delete( + named name: String, + at projectPath: String + ) throws { + let path = Self.slashCommandsDir(for: projectPath) + "/" + name + ".md" + let transport = context.makeTransport() + guard transport.fileExists(path) else { return } + try transport.removeFile(path) + } + + // MARK: - Expansion + + /// Render the command's body for sending to the agent. Substitutes + /// `{{argument}}` (and `{{argument | default: "..."}}`) with the + /// supplied argument. The result is what `ChatViewModel.sendPrompt` + /// transmits as a normal user message. + /// + /// The expansion also prepends a Scarf-managed marker so the agent + /// can correlate the prompt back to the slash command — useful when + /// the agent is asked "what command did the user run?". + public nonisolated func expand( + _ command: ProjectSlashCommand, + withArgument argument: String + ) -> String { + let trimmed = argument.trimmingCharacters(in: .whitespacesAndNewlines) + let body = Self.substituteArgument(in: command.body, with: trimmed) + return "\n\(body)" + } + + // MARK: - Errors + + public enum ServiceError: Error, LocalizedError { + case invalidName(String) + case encodingFailed + + public var errorDescription: String? { + switch self { + case .invalidName(let reason): return reason + case .encodingFailed: return "Couldn't encode the slash command — please check for unusual characters in the body." + } + } + } + + // MARK: - Path helpers + + /// `/.scarf/slash-commands` — same path on Mac + iOS. + public static func slashCommandsDir(for projectPath: String) -> String { + let trimmed = projectPath.hasSuffix("/") + ? String(projectPath.dropLast()) + : projectPath + return trimmed + "/.scarf/slash-commands" + } +} + +// MARK: - Frontmatter parsing + serialisation + +extension ProjectSlashCommandService { + /// Parse a Markdown file with YAML frontmatter into a + /// `ProjectSlashCommand`. Returns nil when the frontmatter is missing + /// or required fields can't be extracted. Reuses `HermesYAML` so we + /// don't pull in a third-party YAML dependency. + static func parse(_ raw: String, sourcePath: String) -> ProjectSlashCommand? { + guard let (frontmatter, body) = splitFrontmatter(raw) else { + #if canImport(os) + logger.warning( + "missing frontmatter at \(sourcePath, privacy: .public); skipping" + ) + #endif + return nil + } + let parsed = HermesYAML.parseNestedYAML(frontmatter) + guard let name = parsed.values["name"], !name.isEmpty, + let description = parsed.values["description"], !description.isEmpty + else { + #if canImport(os) + logger.warning( + "frontmatter missing required name/description at \(sourcePath, privacy: .public); skipping" + ) + #endif + return nil + } + return ProjectSlashCommand( + name: name, + description: description, + argumentHint: parsed.values["argumentHint"], + model: parsed.values["model"], + tags: parsed.lists["tags"], + body: body, + sourcePath: sourcePath + ) + } + + /// Serialise a command to the on-disk format. Round-trip-safe with + /// `parse(_:sourcePath:)` for any value that doesn't contain newlines + /// or YAML-reserved characters in its frontmatter scalars. + static func serialise(_ command: ProjectSlashCommand) -> String { + var fm = "---\n" + fm += "name: \(command.name)\n" + fm += "description: \(yamlScalar(command.description))\n" + if let hint = command.argumentHint, !hint.isEmpty { + fm += "argumentHint: \(yamlScalar(hint))\n" + } + if let model = command.model, !model.isEmpty { + fm += "model: \(yamlScalar(model))\n" + } + if let tags = command.tags, !tags.isEmpty { + fm += "tags:\n" + for tag in tags { + fm += " - \(yamlScalar(tag))\n" + } + } + fm += "---\n" + // Body always ends with one trailing newline so editors don't + // produce diffs on save when the user typed cleanly. + var body = command.body + if !body.hasSuffix("\n") { body += "\n" } + return fm + body + } + + /// Wrap a scalar in double quotes when it contains characters that + /// HermesYAML's parser treats as structural (`:`, `#`, leading `-`, + /// etc.). Otherwise emit it bare. + private static func yamlScalar(_ value: String) -> String { + let needsQuoting = value.contains(":") + || value.contains("#") + || value.contains("\"") + || value.hasPrefix("-") + || value.hasPrefix("[") + || value.hasPrefix("{") + || value.hasPrefix(">") + || value.hasPrefix("|") + if !needsQuoting { return value } + let escaped = value.replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } + + /// Split a Markdown-with-frontmatter string at the closing `---`. + /// Returns `(frontmatter, body)` or nil when no frontmatter is found. + /// The opening `---` must be the very first line of the file (any + /// leading whitespace/newlines disqualify the file — keeps the + /// detection unambiguous). + static func splitFrontmatter(_ raw: String) -> (frontmatter: String, body: String)? { + let lines = raw.components(separatedBy: "\n") + guard lines.first == "---" else { return nil } + for (idx, line) in lines.enumerated() where idx > 0 && line == "---" { + let frontmatter = lines[1..= lines.count { + body = "" + } else { + // Drop a single blank line right after `---` (common + // Markdown style; preserves the body's first real line). + var bodyLines = Array(lines[bodyStart...]) + if bodyLines.first == "" { bodyLines.removeFirst() } + body = bodyLines.joined(separator: "\n") + } + return (frontmatter, body) + } + return nil + } + + /// Replace `{{argument}}` and `{{argument | default: "..."}}` in the + /// template body with the user-supplied argument. Default value is + /// used when the argument is empty / whitespace-only. + static func substituteArgument(in template: String, with argument: String) -> String { + var result = template + // Match {{argument | default: "..."}} first (more specific). + let defaultPattern = #"\{\{\s*argument\s*\|\s*default:\s*"((?:[^"\\]|\\.)*)"\s*\}\}"# + if let regex = try? NSRegularExpression(pattern: defaultPattern) { + let range = NSRange(result.startIndex..., in: result) + let matches = regex.matches(in: result, range: range).reversed() + for match in matches { + guard let fullRange = Range(match.range, in: result), + let defaultRange = Range(match.range(at: 1), in: result) + else { continue } + let replacement = argument.isEmpty + ? String(result[defaultRange]) + : argument + result.replaceSubrange(fullRange, with: replacement) + } + } + // Then plain {{argument}} for anything that didn't have a default. + result = result.replacingOccurrences( + of: #"\{\{\s*argument\s*\}\}"#, + with: argument, + options: .regularExpression + ) + return result + } +} diff --git a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift index 5c03195..c2bebeb 100644 --- a/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift +++ b/scarf/Packages/ScarfCore/Sources/ScarfCore/ViewModels/RichChatViewModel.swift @@ -189,11 +189,41 @@ public final class RichChatViewModel { public private(set) var acpCommands: [HermesSlashCommand] = [] /// User-defined commands parsed from `config.yaml` `quick_commands`. public private(set) var quickCommands: [HermesSlashCommand] = [] + /// Project-scoped, Scarf-managed commands at + /// `/.scarf/slash-commands/.md`. Loaded by + /// `loadProjectScopedCommands(at:)` when a project chat starts; cleared + /// on `reset()`. The full `ProjectSlashCommand` payload is kept here + /// (not just the surface metadata) because expansion happens in + /// `ChatViewModel.sendPrompt` and needs the body + model override. + public private(set) var projectScopedCommands: [ProjectSlashCommand] = [] - /// Merged list, ACP-first, de-duplicated by name. + /// Merged slash-menu list. Precedence: **ACP > project-scoped > + /// quick_commands** (most specific source wins). De-duplicated by name. public var availableCommands: [HermesSlashCommand] { let acpNames = Set(acpCommands.map(\.name)) - return acpCommands + quickCommands.filter { !acpNames.contains($0.name) } + let projectAsHermes: [HermesSlashCommand] = projectScopedCommands + .filter { !acpNames.contains($0.name) } + .map { cmd in + HermesSlashCommand( + name: cmd.name, + description: cmd.description, + argumentHint: cmd.argumentHint, + source: .projectScoped + ) + } + let projectNames = Set(projectAsHermes.map(\.name)) + let quicks = quickCommands.filter { + !acpNames.contains($0.name) && !projectNames.contains($0.name) + } + return acpCommands + projectAsHermes + quicks + } + + /// Look up the full project-scoped command payload by slash trigger. + /// `ChatViewModel.sendPrompt` calls this when the input matches a + /// `.projectScoped` source and needs the body for client-side + /// expansion. + public func projectScopedCommand(named name: String) -> ProjectSlashCommand? { + projectScopedCommands.first { $0.name == name } } public var supportsCompress: Bool { availableCommands.contains { $0.name == "compress" } } @@ -270,6 +300,7 @@ public final class RichChatViewModel { acpErrorDetails = nil acpCachedReadTokens = 0 acpCommands = [] + projectScopedCommands = [] pendingPermission = nil loadQuickCommands() } @@ -408,6 +439,27 @@ public final class RichChatViewModel { } } + /// Load project-scoped slash commands from + /// `/.scarf/slash-commands/` off the main actor and + /// publish them. Safe to call repeatedly — replaces the existing + /// list (e.g., when the user adds / edits / deletes commands). + /// Pass `nil` to clear (e.g., on session de-attribution from a + /// project, or quick-chat sessions). + public func loadProjectScopedCommands(at projectPath: String?) { + guard let projectPath else { + projectScopedCommands = [] + return + } + let ctx = context + Task.detached { [weak self] in + let svc = ProjectSlashCommandService(context: ctx) + let loaded = svc.loadCommands(at: projectPath) + await MainActor.run { [weak self] in + self?.projectScopedCommands = loaded + } + } + } + /// Parse `quick_commands` from `/config.yaml`. Returns /// `[(name, command)]` for every well-formed `type: exec` entry. /// Mac-side `QuickCommandsViewModel` uses a richer model + adds diff --git a/scarf/Scarf iOS/Chat/ChatView.swift b/scarf/Scarf iOS/Chat/ChatView.swift index c5f97e9..c76e14b 100644 --- a/scarf/Scarf iOS/Chat/ChatView.swift +++ b/scarf/Scarf iOS/Chat/ChatView.swift @@ -533,8 +533,14 @@ final class ChatController { guard !sessionId.isEmpty else { return } draft = "" vm.addUserMessage(text: text) + // Project-scoped slash commands expand client-side: the user + // bubble shows the literal `/ args` they typed (above); + // Hermes receives the expanded prompt template body. Other + // command sources (ACP, quick_commands) keep going to Hermes + // literally. v2.5. + let wireText = expandIfProjectScoped(text) do { - _ = try await client.sendPrompt(sessionId: sessionId, text: text) + _ = try await client.sendPrompt(sessionId: sessionId, text: wireText) } catch { // The event task may already have surfaced a // .connectionLost; show the send-time error only if the @@ -548,6 +554,28 @@ final class ChatController { } } + /// Mirror of `ChatViewModel.expandIfProjectScoped(_:)` on Mac. + /// `/ args` matching a loaded project-scoped command is + /// expanded; everything else is sent literally. + private func expandIfProjectScoped(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("/") else { return text } + let withoutSlash = String(trimmed.dropFirst()) + let name: String + let argument: String + if let space = withoutSlash.firstIndex(of: " ") { + name = String(withoutSlash[../.scarf/slash-commands/ into the chat menu. + // Async + non-fatal — degrades cleanly on SFTP failures (logged). + vm.loadProjectScopedCommands(at: project.path) // Write the context block first. Non-fatal on failure — chat // still starts, just without the managed block. We capture the // failure (rather than swallowing via `try?`) so the user gets @@ -722,16 +757,20 @@ final class ChatController { // JSON-decode edge case that would render just a folder icon // with no text (pass-2 bug: user saw exactly that). let ctx = context - let resolved: String? = await Task.detached { + // Resolve both the path AND the name so we can (a) render the + // header chip with the name and (b) load any project-scoped + // slash commands at the project's `.scarf/slash-commands/` dir. + let resolved: (path: String, name: String)? = await Task.detached { let attribution = SessionAttributionService(context: ctx) guard let path = attribution.projectPath(for: sessionID) else { return nil } let registry = ProjectDashboardService(context: ctx).loadRegistry() guard let name = registry.projects.first(where: { $0.path == path })?.name, !name.isEmpty else { return nil } - return name + return (path: path, name: name) }.value - currentProjectName = resolved + currentProjectName = resolved?.name + vm.loadProjectScopedCommands(at: resolved?.path) state = .connecting let client = ACPClient.forIOSApp( diff --git a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift index ae85502..27cf596 100644 --- a/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift +++ b/scarf/scarf/Features/Chat/ViewModels/ChatViewModel.swift @@ -267,6 +267,33 @@ final class ChatViewModel { } } + /// If `text` is a `/ [args]` invocation matching a project- + /// scoped slash command currently loaded into the view model, return + /// the expanded prompt body (with `{{argument}}` substituted). Otherwise + /// return the input unchanged. + /// + /// ACP commands and `quick_commands:` keep going to Hermes literally — + /// only project-scoped commands get the client-side expansion treatment + /// because Hermes has no concept of them. + private func expandIfProjectScoped(_ text: String) -> String { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.hasPrefix("/") else { return text } + let withoutSlash = String(trimmed.dropFirst()) + let name: String + let argument: String + if let space = withoutSlash.firstIndex(of: " ") { + name = String(withoutSlash[.. args` they typed (already in the + // transcript as their bubble), but Hermes receives the expanded + // prompt template. The literal slash is meaningless to Hermes + // for project-scoped commands; this is what makes them portable + // and Hermes-version-independent. v2.5. + let wireText = expandIfProjectScoped(text) + acpStatus = "Agent working..." acpPromptTask = Task { @MainActor in do { - let result = try await client.sendPrompt(sessionId: sessionId, text: text) + let result = try await client.sendPrompt(sessionId: sessionId, text: wireText) acpStatus = "Ready" richChatViewModel.handleACPEvent( .promptComplete(sessionId: sessionId, response: result) @@ -420,11 +455,18 @@ final class ChatViewModel { let name = registry.projects.first(where: { $0.path == path })?.name self.currentProjectPath = path self.currentProjectName = name ?? path + // Pull any project-scoped slash commands the user has + // authored at /.scarf/slash-commands/ so the + // chat slash menu surfaces them. Async + non-fatal — + // the menu degrades to ACP + quick commands only on + // any failure (logged inside the service). + self.richChatViewModel.loadProjectScopedCommands(at: path) } else { // Explicit clear on non-project sessions so the // indicator doesn't leak from a previous chat. self.currentProjectPath = nil self.currentProjectName = nil + self.richChatViewModel.loadProjectScopedCommands(at: nil) } // Refresh session list so the new ACP session appears in the Resume menu