mirror of
https://github.com/awizemann/scarf.git
synced 2026-05-10 02:26:37 +00:00
feat(slash-commands): portable project-scoped slash commands (Phase 1.1-1.4)
Net-new Scarf primitive — Hermes has no project-scoped slash command
concept. Commands live at <project>/.scarf/slash-commands/<name>.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) <noreply@anthropic.com>
This commit is contained in:
@@ -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.<name>` in `~/.hermes/config.yaml`
|
||||
/// (legacy). Sent to the agent as the literal slash text.
|
||||
case quickCommand
|
||||
/// Project-scoped, Scarf-managed command at
|
||||
/// `<project>/.scarf/slash-commands/<name>.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 }
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import Foundation
|
||||
|
||||
/// A user-authored, project-scoped slash command. Lives at
|
||||
/// `<project>/.scarf/slash-commands/<name>.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 `<!-- scarf-slash:<name> -->`
|
||||
/// 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 `/<name> ` in the menu (e.g. `<focus area>`).
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,316 @@
|
||||
import Foundation
|
||||
#if canImport(os)
|
||||
import os
|
||||
#endif
|
||||
|
||||
/// Loads, saves, and expands user-authored project-scoped slash commands
|
||||
/// stored at `<project>/.scarf/slash-commands/<name>.md`.
|
||||
///
|
||||
/// Each command is a Markdown file with a YAML frontmatter block:
|
||||
///
|
||||
/// ```markdown
|
||||
/// ---
|
||||
/// name: review
|
||||
/// description: Code-review the current branch
|
||||
/// argumentHint: <focus area>
|
||||
/// 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 `<project>/.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 `<project>/.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 "<!-- scarf-slash:\(command.name) -->\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
|
||||
|
||||
/// `<project>/.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..<idx].joined(separator: "\n")
|
||||
let bodyStart = idx + 1
|
||||
let body: String
|
||||
if bodyStart >= 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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
/// `<project>/.scarf/slash-commands/<name>.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
|
||||
/// `<projectPath>/.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 `<context>/config.yaml`. Returns
|
||||
/// `[(name, command)]` for every well-formed `type: exec` entry.
|
||||
/// Mac-side `QuickCommandsViewModel` uses a richer model + adds
|
||||
|
||||
@@ -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 `/<name> 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.
|
||||
/// `/<name> 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[..<space])
|
||||
argument = String(withoutSlash[withoutSlash.index(after: space)...])
|
||||
} else {
|
||||
name = withoutSlash
|
||||
argument = ""
|
||||
}
|
||||
guard !name.isEmpty,
|
||||
let cmd = vm.projectScopedCommand(named: name)
|
||||
else { return text }
|
||||
return ProjectSlashCommandService(context: context).expand(cmd, withArgument: argument)
|
||||
}
|
||||
|
||||
/// Stop the current session + tear down the SSH exec channel.
|
||||
/// Idempotent.
|
||||
func stop() async {
|
||||
@@ -565,6 +593,9 @@ final class ChatController {
|
||||
await stop()
|
||||
vm.reset()
|
||||
currentProjectName = nil
|
||||
// Quick-chat sessions don't have a project; clear any leftover
|
||||
// project-scoped slash commands from a prior session.
|
||||
vm.loadProjectScopedCommands(at: nil)
|
||||
await start()
|
||||
}
|
||||
|
||||
@@ -577,6 +608,10 @@ final class ChatController {
|
||||
await stop()
|
||||
vm.reset()
|
||||
currentProjectName = project.name
|
||||
// Pull any project-authored slash commands at
|
||||
// <project.path>/.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(
|
||||
|
||||
@@ -267,6 +267,33 @@ final class ChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// If `text` is a `/<name> [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[..<space])
|
||||
argument = String(withoutSlash[withoutSlash.index(after: space)...])
|
||||
} else {
|
||||
name = withoutSlash
|
||||
argument = ""
|
||||
}
|
||||
guard !name.isEmpty,
|
||||
let cmd = richChatViewModel.projectScopedCommand(named: name)
|
||||
else { return text }
|
||||
return ProjectSlashCommandService(context: context).expand(cmd, withArgument: argument)
|
||||
}
|
||||
|
||||
private func sendViaACP(client: ACPClient, text: String) {
|
||||
guard let sessionId = richChatViewModel.sessionId else {
|
||||
clearACPErrorState()
|
||||
@@ -280,10 +307,18 @@ final class ChatViewModel {
|
||||
richChatViewModel.addUserMessage(text: text)
|
||||
}
|
||||
|
||||
// Project-scoped slash commands expand client-side: the user
|
||||
// sees the literal `/<name> 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 <path>/.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
|
||||
|
||||
Reference in New Issue
Block a user