Merge branch 'main' into scarf-mobile-development (v2.3.0)
Brings the iOS companion branch current with main's v2.2.0, v2.2.1,
and v2.3.0 landings — templates + configuration + catalog (v2.2),
projects folder hierarchy + per-project Sessions sidecar + AGENTS.md
context block + Tool Gateway + Nous Portal OAuth + hermes dashboard
webview (v2.3), and credential-pool OAuth expiry + Nous agent-key
rotation (post-v2.3).
Resolutions:
- ScarfCore Models (HermesConfig, ProjectDashboard, HermesPathSet) —
forward-ported Tool Gateway's platformToolsets, project-registry v2
folder/archived fields, and sessionProjectMap path into the moved
ScarfCore copies. Deleted the old Mac-target paths.
- ScarfCore ModelCatalogService — merged main's overlay-only provider
support (Nous Portal + OpenAI Codex + Qwen OAuth + …) so iOS and
macOS pickers see the same provider list. Widened HermesProviderInfo
/ HermesProviderOverlay APIs to public.
- ScarfCore ProjectsViewModel — layered main's v2.3 registry verbs
(moveProject / renameProject / archive / unarchive / folders) onto
the M0d-extracted VM, keeping public surface for the Mac target.
- ScarfCore ConnectionStatusViewModel / RichChatViewModel — widened
`private(set)` to `public private(set)` so Mac views can read
status, lastSuccess, acp*Tokens, originSessionId, acpCommands,
quickCommands.
- ScarfCore HermesConfig+YAML — added platform_toolsets parsing to
the iOS YAML path so config.yaml round-trips the same as macOS.
- RichChatViewModel quick-commands — inlined the Mac-target's
QuickCommandsViewModel.loadQuickCommands into ScarfCore using the
existing HermesYAML parser, removing the cross-module dependency.
- HealthViewModel — took main's Tool Gateway + hermes-dashboard
webview sections wholesale; file stays macOS-only.
- ChatView auto-merge — confirmed resume-session fix (5ae8db2) is
present; made the PendingPermission.id extension public to satisfy
Identifiable conformance across module boundary.
- ProjectSessionsViewModel — moved back to the Mac target since it
depends on SessionAttributionService (also Mac-target). Defer the
iOS SFTP parity of attribution to M7.
- LocalTransport.runProcess + SSHTransport.runLocal — wrapped the
Process body in `#if !os(iOS)` with an explicit throw on iOS so
ScarfCore compiles under the iOS SDK. iOS uses
CitadelServerTransport (ScarfIOS) as the real implementation.
- CitadelServerTransport — updated `sftp.remove(atPath:)` to
`sftp.remove(at:)` for the current Citadel API shape.
Cross-module imports: added `import ScarfCore` to 25 Mac-target files
that consumed ScarfCore types (13 v2.3 additions + 12 post-merge
errors caught by MemberImportVisibility: Settings tabs, SidebarView,
MCPServerEditorView, TemplateExportSheet, tests).
Version lockstep: bumped `scarf mobile` target to
MARKETING_VERSION=2.3.0, CURRENT_PROJECT_VERSION=25 to match main.
Builds green for both schemes:
- swift build (ScarfCore standalone)
- xcodebuild scarf -destination platform=macOS
- xcodebuild 'scarf mobile' -destination generic/platform=iOS
Deferred to M7 (iOS SFTP parity):
- NousSubscriptionService auth.json reader
- ProjectAgentContextService AGENTS.md write-before-chat
- SessionAttributionService session_project_map.json read/watch
All currently Mac-target-gated; iOS still builds without them.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@@ -627,6 +627,13 @@ public struct HermesConfig: Sendable {
|
||||
public var prefillMessagesFile: String
|
||||
public var skillsExternalDirs: [String]
|
||||
|
||||
/// Per-platform toolset allowlists as written by `hermes setup tools`.
|
||||
/// Keyed by platform (`cli`, `slack`, …) to enabled toolset identifiers
|
||||
/// (`browser`, `messaging`, `nous-tools`, …). Hermes v0.10.0's Tool
|
||||
/// Gateway; enabling `nous-tools` here is how subscribers opt-in per
|
||||
/// platform. Scarf reads for display; edits go through Hermes CLI.
|
||||
public var platformToolsets: [String: [String]]
|
||||
|
||||
// Grouped blocks
|
||||
public var display: DisplaySettings
|
||||
public var terminal: TerminalSettings
|
||||
@@ -686,6 +693,7 @@ public struct HermesConfig: Sendable {
|
||||
cronWrapResponse: Bool,
|
||||
prefillMessagesFile: String,
|
||||
skillsExternalDirs: [String],
|
||||
platformToolsets: [String: [String]],
|
||||
display: DisplaySettings,
|
||||
terminal: TerminalSettings,
|
||||
browser: BrowserSettings,
|
||||
@@ -742,6 +750,7 @@ public struct HermesConfig: Sendable {
|
||||
self.cronWrapResponse = cronWrapResponse
|
||||
self.prefillMessagesFile = prefillMessagesFile
|
||||
self.skillsExternalDirs = skillsExternalDirs
|
||||
self.platformToolsets = platformToolsets
|
||||
self.display = display
|
||||
self.terminal = terminal
|
||||
self.browser = browser
|
||||
@@ -799,6 +808,7 @@ public struct HermesConfig: Sendable {
|
||||
cronWrapResponse: true,
|
||||
prefillMessagesFile: "",
|
||||
skillsExternalDirs: [],
|
||||
platformToolsets: [:],
|
||||
display: .empty,
|
||||
terminal: .empty,
|
||||
browser: .empty,
|
||||
|
||||
@@ -65,6 +65,10 @@ public struct HermesPathSet: Sendable, Hashable {
|
||||
public nonisolated var gatewayLog: String { home + "/logs/gateway.log" }
|
||||
public nonisolated var scarfDir: String { home + "/scarf" }
|
||||
public nonisolated var projectsRegistry: String { scarfDir + "/projects.json" }
|
||||
|
||||
/// Maps Hermes session IDs to the Scarf project path a chat was
|
||||
/// started for. Scarf-owned; Hermes never touches this file.
|
||||
public nonisolated var sessionProjectMap: String { scarfDir + "/session_project_map.json" }
|
||||
public nonisolated var mcpTokensDir: String { home + "/mcp-tokens" }
|
||||
|
||||
// MARK: - Binary resolution
|
||||
|
||||
@@ -17,15 +17,51 @@ public struct ProjectEntry: Codable, Sendable, Identifiable, Hashable {
|
||||
public let name: String
|
||||
public let path: String
|
||||
|
||||
/// Folder path for sidebar grouping. `nil` means top-level.
|
||||
/// v2.3 registry schema v2; v2.2 files decode cleanly as `nil`.
|
||||
public var folder: String?
|
||||
|
||||
/// Soft-archive flag. Archived projects are hidden from the sidebar
|
||||
/// by default; non-destructive. v2.3 schema v2; defaults to `false`.
|
||||
public var archived: Bool
|
||||
|
||||
public init(
|
||||
name: String,
|
||||
path: String
|
||||
path: String,
|
||||
folder: String? = nil,
|
||||
archived: Bool = false
|
||||
) {
|
||||
self.name = name
|
||||
self.path = path
|
||||
self.folder = folder
|
||||
self.archived = archived
|
||||
}
|
||||
|
||||
public var dashboardPath: String { path + "/.scarf/dashboard.json" }
|
||||
|
||||
// MARK: - Codable (custom for backward compat)
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case name, path, folder, archived
|
||||
}
|
||||
|
||||
public init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
self.name = try c.decode(String.self, forKey: .name)
|
||||
self.path = try c.decode(String.self, forKey: .path)
|
||||
self.folder = try c.decodeIfPresent(String.self, forKey: .folder)
|
||||
self.archived = try c.decodeIfPresent(Bool.self, forKey: .archived) ?? false
|
||||
}
|
||||
|
||||
public func encode(to encoder: Encoder) throws {
|
||||
var c = encoder.container(keyedBy: CodingKeys.self)
|
||||
try c.encode(name, forKey: .name)
|
||||
try c.encode(path, forKey: .path)
|
||||
try c.encodeIfPresent(folder, forKey: .folder)
|
||||
if archived {
|
||||
try c.encode(archived, forKey: .archived)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dashboard
|
||||
|
||||
@@ -205,6 +205,16 @@ public extension HermesConfig {
|
||||
replyPrefix: str("whatsapp.reply_prefix")
|
||||
)
|
||||
|
||||
// `platform_toolsets.<platform>` is a dict of lists in config.yaml —
|
||||
// parseNestedYAML flattens nested lists into dotted-path keys. Pull
|
||||
// every key under the prefix and strip it.
|
||||
var platformToolsets: [String: [String]] = [:]
|
||||
for (key, items) in lists where key.hasPrefix("platform_toolsets.") {
|
||||
let platform = String(key.dropFirst("platform_toolsets.".count))
|
||||
guard !platform.isEmpty else { continue }
|
||||
platformToolsets[platform] = items
|
||||
}
|
||||
|
||||
// Home Assistant lives under `platforms.homeassistant.extra.*`.
|
||||
let homeAssistant = HomeAssistantSettings(
|
||||
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
|
||||
@@ -252,6 +262,7 @@ public extension HermesConfig {
|
||||
cronWrapResponse: bool("cron.wrap_response", default: true),
|
||||
prefillMessagesFile: str("prefill_messages_file"),
|
||||
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
|
||||
platformToolsets: platformToolsets,
|
||||
display: display,
|
||||
terminal: terminal,
|
||||
browser: browser,
|
||||
|
||||
@@ -70,19 +70,32 @@ public struct HermesProviderInfo: Sendable, Identifiable, Hashable {
|
||||
public let envVars: [String] // e.g. ["ANTHROPIC_API_KEY"]
|
||||
public let docURL: String?
|
||||
public let modelCount: Int
|
||||
/// True when this provider is surfaced only by the Hermes overlay list —
|
||||
/// i.e. no entry in `models_dev_cache.json`. The picker renders a
|
||||
/// different right-column affordance (subscription CTA or free-form
|
||||
/// model entry).
|
||||
public let isOverlay: Bool
|
||||
/// True for providers whose tool access is subscription-gated rather
|
||||
/// than BYO API key. Nous Portal is the only such provider as of
|
||||
/// hermes-agent v0.10.0.
|
||||
public let subscriptionGated: Bool
|
||||
|
||||
public init(
|
||||
providerID: String,
|
||||
providerName: String,
|
||||
envVars: [String],
|
||||
docURL: String?,
|
||||
modelCount: Int
|
||||
modelCount: Int,
|
||||
isOverlay: Bool = false,
|
||||
subscriptionGated: Bool = false
|
||||
) {
|
||||
self.providerID = providerID
|
||||
self.providerName = providerName
|
||||
self.envVars = envVars
|
||||
self.docURL = docURL
|
||||
self.modelCount = modelCount
|
||||
self.isOverlay = isOverlay
|
||||
self.subscriptionGated = subscriptionGated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,20 +124,49 @@ public struct ModelCatalogService: Sendable {
|
||||
self.transport = LocalTransport()
|
||||
}
|
||||
|
||||
/// All providers, sorted by display name.
|
||||
/// All providers, sorted with subscription-gated providers first (Nous
|
||||
/// Portal), then alphabetical by display name. Merges the models.dev
|
||||
/// cache with `Self.overlayOnlyProviders` so Hermes-injected providers
|
||||
/// (Nous Portal, OpenAI Codex, …) appear in the picker even when
|
||||
/// they're absent from `models_dev_cache.json`.
|
||||
public func loadProviders() -> [HermesProviderInfo] {
|
||||
guard let catalog = loadCatalog() else { return [] }
|
||||
return catalog
|
||||
.map { (id, p) in
|
||||
HermesProviderInfo(
|
||||
providerID: id,
|
||||
providerName: p.name ?? id,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
)
|
||||
let catalog = loadCatalog() ?? [:]
|
||||
var byID: [String: HermesProviderInfo] = [:]
|
||||
for (id, p) in catalog {
|
||||
byID[id] = HermesProviderInfo(
|
||||
providerID: id,
|
||||
providerName: p.name ?? id,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0,
|
||||
isOverlay: false,
|
||||
subscriptionGated: false
|
||||
)
|
||||
}
|
||||
for (id, overlay) in Self.overlayOnlyProviders where byID[id] == nil {
|
||||
byID[id] = HermesProviderInfo(
|
||||
providerID: id,
|
||||
providerName: overlay.displayName,
|
||||
envVars: [],
|
||||
docURL: overlay.docURL,
|
||||
modelCount: 0,
|
||||
isOverlay: true,
|
||||
subscriptionGated: overlay.subscriptionGated
|
||||
)
|
||||
}
|
||||
return byID.values.sorted { lhs, rhs in
|
||||
if lhs.subscriptionGated != rhs.subscriptionGated {
|
||||
return lhs.subscriptionGated
|
||||
}
|
||||
.sorted { $0.providerName.localizedCaseInsensitiveCompare($1.providerName) == .orderedAscending }
|
||||
return lhs.providerName.localizedCaseInsensitiveCompare(rhs.providerName) == .orderedAscending
|
||||
}
|
||||
}
|
||||
|
||||
/// Overlay metadata for a provider that isn't in the models.dev catalog —
|
||||
/// Scarf needs to surface these so the picker matches `hermes model` on
|
||||
/// the CLI.
|
||||
public func overlayMetadata(for providerID: String) -> HermesProviderOverlay? {
|
||||
Self.overlayOnlyProviders[providerID]
|
||||
}
|
||||
|
||||
/// Models for one provider, sorted by release date (newest first), then name.
|
||||
@@ -167,7 +209,9 @@ public struct ModelCatalogService: Sendable {
|
||||
providerName: p.name ?? providerID,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
modelCount: p.models?.count ?? 0,
|
||||
isOverlay: false,
|
||||
subscriptionGated: false
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -181,13 +225,45 @@ public struct ModelCatalogService: Sendable {
|
||||
providerName: p.name ?? prefix,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0
|
||||
modelCount: p.models?.count ?? 0,
|
||||
isOverlay: false,
|
||||
subscriptionGated: false
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Look up a provider by ID, falling back to overlays when the cache has
|
||||
/// no entry. Use this when resolving a stored `model.provider` to display
|
||||
/// metadata — `nous` and other overlay-only IDs never appear in the
|
||||
/// cache, so a plain catalog lookup returns nil for them.
|
||||
public func providerByID(_ providerID: String) -> HermesProviderInfo? {
|
||||
if let catalog = loadCatalog(), let p = catalog[providerID] {
|
||||
return HermesProviderInfo(
|
||||
providerID: providerID,
|
||||
providerName: p.name ?? providerID,
|
||||
envVars: p.env ?? [],
|
||||
docURL: p.doc,
|
||||
modelCount: p.models?.count ?? 0,
|
||||
isOverlay: false,
|
||||
subscriptionGated: false
|
||||
)
|
||||
}
|
||||
if let overlay = Self.overlayOnlyProviders[providerID] {
|
||||
return HermesProviderInfo(
|
||||
providerID: providerID,
|
||||
providerName: overlay.displayName,
|
||||
envVars: [],
|
||||
docURL: overlay.docURL,
|
||||
modelCount: 0,
|
||||
isOverlay: true,
|
||||
subscriptionGated: overlay.subscriptionGated
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Look up a specific model by provider + ID. Returns nil if not in the
|
||||
/// catalog (e.g., free-typed custom model).
|
||||
public func model(providerID: String, modelID: String) -> HermesModelInfo? {
|
||||
@@ -253,4 +329,93 @@ public struct ModelCatalogService: Sendable {
|
||||
let context: Int?
|
||||
let output: Int?
|
||||
}
|
||||
|
||||
// MARK: - Hermes overlay providers
|
||||
|
||||
/// The six providers Hermes surfaces via `hermes model` that have no
|
||||
/// entry in `models_dev_cache.json` (models.dev doesn't mirror them).
|
||||
/// Mirrors the overlay-only subset of `HERMES_OVERLAYS` in
|
||||
/// `hermes-agent/hermes_cli/providers.py`. The other ~19 overlay entries
|
||||
/// already ship in the cache and only add augmentation (base-URL
|
||||
/// override, extra env vars) that Scarf doesn't currently display.
|
||||
///
|
||||
/// Keep this in sync with the Python side on Hermes version bumps.
|
||||
static let overlayOnlyProviders: [String: HermesProviderOverlay] = [
|
||||
"nous": HermesProviderOverlay(
|
||||
displayName: "Nous Portal",
|
||||
baseURL: "https://inference-api.nousresearch.com/v1",
|
||||
authType: .oauthDeviceCode,
|
||||
subscriptionGated: true,
|
||||
docURL: "https://hermes-agent.nousresearch.com/docs/user-guide/setup/nous-portal"
|
||||
),
|
||||
"openai-codex": HermesProviderOverlay(
|
||||
displayName: "OpenAI Codex",
|
||||
baseURL: "https://chatgpt.com/backend-api/codex",
|
||||
authType: .oauthExternal,
|
||||
subscriptionGated: false,
|
||||
docURL: nil
|
||||
),
|
||||
"qwen-oauth": HermesProviderOverlay(
|
||||
displayName: "Qwen (OAuth)",
|
||||
baseURL: "https://portal.qwen.ai/v1",
|
||||
authType: .oauthExternal,
|
||||
subscriptionGated: false,
|
||||
docURL: nil
|
||||
),
|
||||
"google-gemini-cli": HermesProviderOverlay(
|
||||
displayName: "Google Gemini CLI",
|
||||
baseURL: "cloudcode-pa://google",
|
||||
authType: .oauthExternal,
|
||||
subscriptionGated: false,
|
||||
docURL: nil
|
||||
),
|
||||
"copilot-acp": HermesProviderOverlay(
|
||||
displayName: "GitHub Copilot ACP",
|
||||
baseURL: "acp://copilot",
|
||||
authType: .externalProcess,
|
||||
subscriptionGated: false,
|
||||
docURL: nil
|
||||
),
|
||||
"arcee": HermesProviderOverlay(
|
||||
displayName: "Arcee",
|
||||
baseURL: "https://api.arcee.ai/api/v1",
|
||||
authType: .apiKey,
|
||||
subscriptionGated: false,
|
||||
docURL: nil
|
||||
),
|
||||
]
|
||||
}
|
||||
|
||||
/// Scarf-side mirror of `HermesOverlay` from hermes-agent's
|
||||
/// `hermes_cli/providers.py`. Describes a provider that isn't in the
|
||||
/// models.dev catalog.
|
||||
public struct HermesProviderOverlay: Sendable {
|
||||
public let displayName: String
|
||||
public let baseURL: String?
|
||||
public let authType: AuthType
|
||||
/// True for providers whose tool access is subscription-gated rather than
|
||||
/// BYO-API-key. Nous Portal is the only `true` entry today.
|
||||
public let subscriptionGated: Bool
|
||||
public let docURL: String?
|
||||
|
||||
public init(
|
||||
displayName: String,
|
||||
baseURL: String?,
|
||||
authType: AuthType,
|
||||
subscriptionGated: Bool,
|
||||
docURL: String?
|
||||
) {
|
||||
self.displayName = displayName
|
||||
self.baseURL = baseURL
|
||||
self.authType = authType
|
||||
self.subscriptionGated = subscriptionGated
|
||||
self.docURL = docURL
|
||||
}
|
||||
|
||||
public enum AuthType: String, Sendable {
|
||||
case apiKey
|
||||
case oauthDeviceCode
|
||||
case oauthExternal
|
||||
case externalProcess
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,11 @@ public struct LocalTransport: ServerTransport {
|
||||
// MARK: - Processes
|
||||
|
||||
public func runProcess(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
|
||||
#if os(iOS)
|
||||
// iOS can't spawn processes. Callers on iOS use `CitadelServerTransport`
|
||||
// (from the ScarfIOS package) instead; reaching here is a wiring bug.
|
||||
throw TransportError.other(message: "LocalTransport.runProcess is unavailable on iOS")
|
||||
#else
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: executable)
|
||||
proc.arguments = args
|
||||
@@ -148,6 +153,7 @@ public struct LocalTransport: ServerTransport {
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
try? stdinPipe.fileHandleForWriting.close()
|
||||
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
|
||||
#endif
|
||||
}
|
||||
|
||||
#if !os(iOS)
|
||||
|
||||
@@ -637,6 +637,11 @@ public struct SSHTransport: ServerTransport {
|
||||
/// SSH-specific code paths live on this type and we want all Process
|
||||
/// lifecycle in one place per transport.
|
||||
nonisolated private func runLocal(executable: String, args: [String], stdin: Data?, timeout: TimeInterval?) throws -> ProcessResult {
|
||||
#if os(iOS)
|
||||
// iOS uses `CitadelServerTransport` instead of spawning ssh/scp
|
||||
// binaries. Reaching here from iOS is a wiring bug.
|
||||
throw TransportError.other(message: "SSHTransport.runLocal is unavailable on iOS")
|
||||
#else
|
||||
ensureControlDir()
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: executable)
|
||||
@@ -682,5 +687,6 @@ public struct SSHTransport: ServerTransport {
|
||||
try? stderrPipe.fileHandleForReading.close()
|
||||
try? stdinPipe.fileHandleForWriting.close()
|
||||
return ProcessResult(exitCode: proc.terminationStatus, stdout: out, stderr: err)
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
@@ -30,15 +30,15 @@ public final class ConnectionStatusViewModel {
|
||||
case error(message: String, stderr: String)
|
||||
}
|
||||
|
||||
private(set) var status: Status = .idle
|
||||
public private(set) var status: Status = .idle
|
||||
/// Timestamp of the last successful probe. Used by the UI to show how
|
||||
/// fresh the status indicator is ("just now", "2m ago"…).
|
||||
private(set) var lastSuccess: Date?
|
||||
public private(set) var lastSuccess: Date?
|
||||
/// Number of consecutive probe failures. Surfaced as a yellow "Reconnecting…"
|
||||
/// state for the first failure (silent retry), then promoted to red after
|
||||
/// `consecutiveFailureThreshold` failures so flaky connections don't
|
||||
/// flap the indicator on every dropped packet.
|
||||
private(set) var consecutiveFailures = 0
|
||||
public private(set) var consecutiveFailures = 0
|
||||
private let consecutiveFailureThreshold = 2
|
||||
|
||||
public let context: ServerContext
|
||||
|
||||
@@ -30,7 +30,7 @@ public final class LogsViewModel {
|
||||
switch self {
|
||||
case .agent: return "Agent"
|
||||
case .errors: return "Errors"
|
||||
case .gateway: return "Gateway"
|
||||
case .gateway: return "Messaging Gateway"
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -58,7 +58,7 @@ public final class LogsViewModel {
|
||||
public var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .all: return "All"
|
||||
case .gateway: return "Gateway"
|
||||
case .gateway: return "Messaging Gateway"
|
||||
case .agent: return "Agent"
|
||||
case .tools: return "Tools"
|
||||
case .cli: return "CLI"
|
||||
|
||||
@@ -73,6 +73,88 @@ public final class ProjectsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - v2.3 registry verbs (folder / archive / rename)
|
||||
|
||||
/// Move a project into a folder. `nil` folder returns the project
|
||||
/// to the top level. No-op when the target already matches.
|
||||
public func moveProject(_ project: ProjectEntry, toFolder folder: String?) {
|
||||
mutateEntry(project) { $0.folder = folder }
|
||||
}
|
||||
|
||||
/// Rename a project. `name` is the registry's unique key + the
|
||||
/// Identifiable id; rejects renames that would collide with an
|
||||
/// existing project's name. Returns true on success.
|
||||
@discardableResult
|
||||
public func renameProject(_ project: ProjectEntry, to newName: String) -> Bool {
|
||||
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return false }
|
||||
guard trimmed != project.name else { return true }
|
||||
var registry = service.loadRegistry()
|
||||
guard !registry.projects.contains(where: { $0.name == trimmed }) else { return false }
|
||||
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return false }
|
||||
let old = registry.projects[index]
|
||||
registry.projects[index] = ProjectEntry(
|
||||
name: trimmed,
|
||||
path: old.path,
|
||||
folder: old.folder,
|
||||
archived: old.archived
|
||||
)
|
||||
do {
|
||||
try service.saveRegistry(registry)
|
||||
} catch {
|
||||
logger.error("renameProject couldn't persist registry: \(error.localizedDescription, privacy: .public)")
|
||||
return false
|
||||
}
|
||||
projects = registry.projects
|
||||
if selectedProject?.name == project.name {
|
||||
selectedProject = registry.projects[index]
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
/// Soft-archive a project. Stays on disk + in the registry; the
|
||||
/// sidebar just hides it unless `showArchived` is on.
|
||||
public func archiveProject(_ project: ProjectEntry) {
|
||||
mutateEntry(project) { $0.archived = true }
|
||||
if selectedProject?.name == project.name {
|
||||
selectedProject = nil
|
||||
dashboard = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore an archived project to the default view.
|
||||
public func unarchiveProject(_ project: ProjectEntry) {
|
||||
mutateEntry(project) { $0.archived = false }
|
||||
}
|
||||
|
||||
/// Distinct folder labels across the current project set, sorted
|
||||
/// alphabetically. Drives the sidebar's DisclosureGroups + the
|
||||
/// Move-to-Folder sheet's existing-folder list.
|
||||
public var folders: [String] {
|
||||
let set = Set(projects.compactMap(\.folder).filter { !$0.isEmpty })
|
||||
return set.sorted()
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func mutateEntry(_ project: ProjectEntry, _ mutation: (inout ProjectEntry) -> Void) {
|
||||
var registry = service.loadRegistry()
|
||||
guard let index = registry.projects.firstIndex(where: { $0.name == project.name }) else { return }
|
||||
var entry = registry.projects[index]
|
||||
mutation(&entry)
|
||||
registry.projects[index] = entry
|
||||
do {
|
||||
try service.saveRegistry(registry)
|
||||
} catch {
|
||||
logger.error("mutateEntry couldn't persist registry for \(project.name, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
return
|
||||
}
|
||||
projects = registry.projects
|
||||
if selectedProject?.name == project.name {
|
||||
selectedProject = entry
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshDashboard() {
|
||||
guard let project = selectedProject else { return }
|
||||
loadDashboard(for: project)
|
||||
|
||||
@@ -50,15 +50,15 @@ public final class RichChatViewModel {
|
||||
public var scrollTrigger = UUID()
|
||||
|
||||
// Cumulative ACP token tracking (ACP returns tokens per prompt but DB has none)
|
||||
private(set) var acpInputTokens = 0
|
||||
private(set) var acpOutputTokens = 0
|
||||
private(set) var acpThoughtTokens = 0
|
||||
private(set) var acpCachedReadTokens = 0
|
||||
public private(set) var acpInputTokens = 0
|
||||
public private(set) var acpOutputTokens = 0
|
||||
public private(set) var acpThoughtTokens = 0
|
||||
public private(set) var acpCachedReadTokens = 0
|
||||
|
||||
/// Slash commands advertised by the ACP server via `available_commands_update`.
|
||||
private(set) var acpCommands: [HermesSlashCommand] = []
|
||||
public private(set) var acpCommands: [HermesSlashCommand] = []
|
||||
/// User-defined commands parsed from `config.yaml` `quick_commands`.
|
||||
private(set) var quickCommands: [HermesSlashCommand] = []
|
||||
public private(set) var quickCommands: [HermesSlashCommand] = []
|
||||
|
||||
/// Merged list, ACP-first, de-duplicated by name.
|
||||
public var availableCommands: [HermesSlashCommand] {
|
||||
@@ -81,7 +81,7 @@ public final class RichChatViewModel {
|
||||
public private(set) var sessionId: String?
|
||||
/// The original CLI session ID when resuming a CLI session via ACP.
|
||||
/// Used to combine old CLI messages with new ACP messages.
|
||||
private(set) var originSessionId: String?
|
||||
public private(set) var originSessionId: String?
|
||||
private var nextLocalId = -1
|
||||
private var streamingAssistantText = ""
|
||||
private var streamingThinkingText = ""
|
||||
@@ -253,13 +253,13 @@ public final class RichChatViewModel {
|
||||
public func loadQuickCommands() {
|
||||
let ctx = context
|
||||
Task.detached { [weak self] in
|
||||
let loaded = QuickCommandsViewModel.loadQuickCommands(context: ctx)
|
||||
let mapped = loaded.map { qc -> HermesSlashCommand in
|
||||
let truncated = qc.command.count > 60
|
||||
? String(qc.command.prefix(60)) + "…"
|
||||
: qc.command
|
||||
let loaded = Self.loadQuickCommands(context: ctx)
|
||||
let mapped = loaded.map { (name, command) -> HermesSlashCommand in
|
||||
let truncated = command.count > 60
|
||||
? String(command.prefix(60)) + "…"
|
||||
: command
|
||||
return HermesSlashCommand(
|
||||
name: qc.name,
|
||||
name: name,
|
||||
description: "Run: \(truncated)",
|
||||
argumentHint: nil,
|
||||
source: .quickCommand
|
||||
@@ -271,6 +271,33 @@ public final class RichChatViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// an `isDangerous` check; here we only need the slash-menu
|
||||
/// projection, so we keep the parser minimal and ScarfCore-local.
|
||||
nonisolated static func loadQuickCommands(context: ServerContext) -> [(name: String, command: String)] {
|
||||
guard let yaml = context.readText(context.paths.configYAML) else { return [] }
|
||||
let parsed = HermesYAML.parseNestedYAML(yaml)
|
||||
var byName: [String: (type: String, command: String)] = [:]
|
||||
for (key, value) in parsed.values where key.hasPrefix("quick_commands.") {
|
||||
let parts = key.split(separator: ".", maxSplits: 2, omittingEmptySubsequences: false)
|
||||
guard parts.count == 3 else { continue }
|
||||
let name = String(parts[1])
|
||||
let field = String(parts[2])
|
||||
var existing = byName[name] ?? (type: "exec", command: "")
|
||||
let stripped = HermesYAML.stripYAMLQuotes(value)
|
||||
if field == "type" { existing.type = stripped }
|
||||
if field == "command" { existing.command = stripped }
|
||||
byName[name] = existing
|
||||
}
|
||||
return byName.compactMap { (name, entry) in
|
||||
guard entry.type == "exec", !entry.command.isEmpty else { return nil }
|
||||
return (name: name, command: entry.command)
|
||||
}
|
||||
.sorted { $0.name < $1.name }
|
||||
}
|
||||
|
||||
private func appendMessageChunk(text: String) {
|
||||
streamingAssistantText += text
|
||||
upsertStreamingMessage()
|
||||
|
||||
@@ -302,7 +302,7 @@ public final class CitadelServerTransport: ServerTransport, @unchecked Sendable
|
||||
// Parallel to LocalTransport: no-op if the file doesn't exist.
|
||||
let exists = try await asyncFileExists(path)
|
||||
if !exists { return }
|
||||
try await sftp.remove(atPath: path)
|
||||
try await sftp.remove(at: path)
|
||||
}
|
||||
|
||||
private func asyncRunProcess(
|
||||
|
||||
@@ -56,3 +56,56 @@ Rich usage analytics pulled from the sessions and messages SQLite tables:
|
||||
|
||||
### 10. Config Editor
|
||||
- Structured form editor for config.yaml with validation
|
||||
|
||||
---
|
||||
|
||||
## Projects System Evolution (post-v2.2.1)
|
||||
|
||||
A parallel backlog specific to the Projects feature. Ordered by dependency: organization first, then per-project attribution via sidecar, then observability built on that attribution, then polish, then platform bets.
|
||||
|
||||
### Shipping in v2.3 (planned — plan file at `~/.claude/plans/`)
|
||||
|
||||
- **Folder hierarchy in the sidebar.** `ProjectEntry` gains optional `folder: String?`. `DisclosureGroup`-based sidebar.
|
||||
- **Rename + archive + search.** Registry verbs + a fuzzy ⌘F search + soft-archive (`archived: Bool?`) with Show/Hide toggle.
|
||||
- **⌘1–⌘9 project jumps.**
|
||||
- **Per-project Sessions tab** alongside Dashboard / Site. Filters the global sessions list by a new `~/.hermes/scarf/session_project_map.json` sidecar that Scarf populates when it starts a chat with a project context.
|
||||
- **New Chat button** on the Sessions tab — spawns `hermes acp` with `cwd = project.path` and attributes the resulting session in the sidecar.
|
||||
|
||||
### v2.4+ — per-project observability
|
||||
|
||||
Depends on v2.3's sidecar being stable. All features below are "filter the existing data by the sidecar's project mapping."
|
||||
|
||||
- **Per-project activity feed.** Extend `ActivityViewModel` with a `projectPath` filter that maps through the sidecar. Dashboard widget type `recent-activity`.
|
||||
- **Per-project token / cost rollup.** `InsightsViewModel.computeAggregates()` already sums over sessions; add a project filter. Widget binding `project.tokens` exposes it to agent-driven dashboards.
|
||||
- **Per-project cron-job filter.** Cron sidebar gains a project dropdown. Template-installed jobs already carry `[tmpl:<id>]` prefixes; match against installed template manifests to attribute.
|
||||
- **Desktop notifications for cron completion.** When a project-attributed cron job finishes (success or failure), fire a `UNUserNotification`. Per-project mute.
|
||||
|
||||
### v2.5+ — platform bets
|
||||
|
||||
Bigger investments with longer arcs.
|
||||
|
||||
- **Hermes upstream: `sessions.cwd` column.** Propose adding a nullable `cwd` (or `workspace_id`) column to Hermes's sessions table, populated on session create. Scarf would prefer the canonical column when available and fall back to the sidecar for pre-upgrade sessions. Requires coordinated Hermes release; filed under platform bets because it cuts the sidecar's blind spot (CLI-started sessions never enter the sidecar today).
|
||||
- **Per-project memory slice.** Hermes reads `MEMORY.md` from a known path. Explore whether Scarf can spawn `hermes acp` with an overridden memory path (per-project `<project>/.scarf/MEMORY.md`) so projects get isolated context. Needs a Hermes-side env var or flag.
|
||||
- **Per-project skills namespace.** Today user-authored skills are flat under `~/.hermes/skills/`. A `~/.hermes/skills/project/<slug>/` namespace parallel to the existing `templates/` namespace would let users install skills *into* a project without a template. Uninstall = drop the folder.
|
||||
- **Cross-project meta-dashboards.** A portfolio view that aggregates widgets from multiple projects — total token spend, combined activity feed, project-health matrix. Useful at 20+ projects.
|
||||
- **Project backup / restore.** One-click zip of `<project>/` + sidecar entries + related Keychain secrets, restorable on another machine. Richer than the existing Export flow (which carries the template shape only).
|
||||
|
||||
### Continuous — UX polish
|
||||
|
||||
Small, shippable at any time. Each is a half-day-to-one-day item.
|
||||
|
||||
- **Drag-and-drop to reorder** projects within a folder and between folders. Would be the first use of `.onDrag`/`.onDrop` in the codebase; establishes the pattern.
|
||||
- **Tags as a secondary axis.** Keep folders as primary, add multi-valued string tags + filter chips at the sidebar top. Decide only if folders feel insufficient after v2.3 lands.
|
||||
- **Favorites / pin** — bubble a project to the top of its folder.
|
||||
- **Recent projects collection** — auto-populated "Recents" row at the top of the sidebar.
|
||||
- **Color labels or SF Symbol icons** per project (Finder-tag-style).
|
||||
- **Project dashboard starter templates** — "blank", "monitor", "feed", "timeline" shapes when creating a bare project (distinct from `.scarftemplate` sharing flow).
|
||||
- **Opportunistic session backfill.** When Scarf loads any session that isn't in the sidecar, peek at first tool call's `working_directory` or `cwd` hint; if it matches a registered project path, write a sidecar entry. Heuristic, not perfect — useful as an "it just works" improvement after v2.3 ships.
|
||||
|
||||
### Research / verification gaps
|
||||
|
||||
Noted during v2.3 planning; chase when relevant:
|
||||
|
||||
- `DisclosureGroup` inside `List(.sidebar)` on macOS — occasional animation glitches with many-rows-expanding. Early prototype will confirm before full commit.
|
||||
- Concurrent sidecar writers from multiple Scarf windows on the same `~/.hermes` — atomic replace handles per-write; reload behavior may lag. Acceptable; revisit if users report stale attribution.
|
||||
- Do Hermes sessions ever persist `cwd` anywhere in `state.db` today that we've missed? If so, we can skip the sidecar and use it directly. Worth a one-hour investigation before starting v2.4 observability work.
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
/* Begin PBXBuildFile section */
|
||||
53495AB62F7B992C00BD31AD /* SwiftTerm in Frameworks */ = {isa = PBXBuildFile; productRef = 53SWIFTTERM0001 /* SwiftTerm */; };
|
||||
53SCARFCORE0010 /* ScarfCore in Frameworks */ = {isa = PBXBuildFile; productRef = 53SCARFCORE0001 /* ScarfCore */; };
|
||||
53SCARFCORE0011 /* ScarfCore in Frameworks (iOS) */ = {isa = PBXBuildFile; productRef = 53SCARFCORE0002 /* ScarfCore */; };
|
||||
53SCARFCORE0011 /* ScarfCore in Frameworks */ = {isa = PBXBuildFile; productRef = 53SCARFCORE0002 /* ScarfCore */; };
|
||||
53SCARFIOS0010 /* ScarfIOS in Frameworks */ = {isa = PBXBuildFile; productRef = 53SCARFIOS0001 /* ScarfIOS */; };
|
||||
53SPARKLE00010 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 53SPARKLE00011 /* Sparkle */; };
|
||||
/* End PBXBuildFile section */
|
||||
@@ -115,7 +115,7 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
53SCARFCORE0011 /* ScarfCore in Frameworks (iOS) */,
|
||||
53SCARFCORE0011 /* ScarfCore in Frameworks */,
|
||||
53SCARFIOS0010 /* ScarfIOS in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
@@ -522,7 +522,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -539,7 +539,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -560,7 +560,7 @@
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CODE_SIGN_ENTITLEMENTS = "Scarf iOS/Scarf_iOS.entitlements";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 1;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
@@ -577,7 +577,7 @@
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 1.0;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = "com.scarf-mobile.app";
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = iphoneos;
|
||||
@@ -819,7 +819,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -833,7 +833,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -855,7 +855,7 @@
|
||||
CODE_SIGN_ENTITLEMENTS = scarf/scarf.entitlements;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
COMBINE_HIDPI_IMAGES = YES;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
ENABLE_APP_SANDBOX = NO;
|
||||
@@ -869,7 +869,7 @@
|
||||
"@executable_path/../Frameworks",
|
||||
);
|
||||
MACOSX_DEPLOYMENT_TARGET = 14.6;
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarf.app;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
REGISTER_APP_GROUPS = YES;
|
||||
@@ -887,12 +887,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -909,12 +909,12 @@
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MACOSX_DEPLOYMENT_TARGET = 26.2;
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfTests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -930,11 +930,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
@@ -950,11 +950,11 @@
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 23;
|
||||
CURRENT_PROJECT_VERSION = 25;
|
||||
DEAD_CODE_STRIPPING = YES;
|
||||
DEVELOPMENT_TEAM = 3Q6X2L86C4;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
MARKETING_VERSION = 2.2.0;
|
||||
MARKETING_VERSION = 2.3.0;
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.scarfUITests;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
STRING_CATALOG_GENERATE_SYMBOLS = NO;
|
||||
|
||||
|
Before Width: | Height: | Size: 4.4 MiB |
|
Before Width: | Height: | Size: 73 KiB |
|
Before Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 962 B |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 274 KiB |
|
Before Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 20 KiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 2.9 MiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 864 B |
|
After Width: | Height: | Size: 2.5 KiB |
|
After Width: | Height: | Size: 195 KiB |
|
After Width: | Height: | Size: 742 KiB |
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 742 KiB |
@@ -1,61 +1,61 @@
|
||||
{
|
||||
"images" : [
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-16x16@1x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-16x16@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-16x16@2x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-16x16@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "16x16"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-32x32@1x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-32x32@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-32x32@2x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-32x32@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "32x32"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-128x128@1x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-128x128@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-128x128@2x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-128x128@2x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "128x128"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-256x256@1x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-256x256@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x 1.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-256x256@2x 1.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "256x256"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-512x512@1x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-512x512@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "1x",
|
||||
"size" : "512x512"
|
||||
},
|
||||
{
|
||||
"filename" : "AW Mac OS Applications-iOS-Default-1024x1024@1x.png",
|
||||
"filename" : "AW Mac OS Applications-macOS-Default-1024x1024@1x.png",
|
||||
"idiom" : "mac",
|
||||
"scale" : "2x",
|
||||
"size" : "512x512"
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import Foundation
|
||||
|
||||
/// Scarf-owned sidecar mapping Hermes session IDs to the Scarf
|
||||
/// project path a chat was started for. Written on session create
|
||||
/// when Scarf spawns `hermes acp` with a project-scoped cwd; read
|
||||
/// by the per-project Sessions tab.
|
||||
///
|
||||
/// Hermes's own `state.db` has no `cwd` column on the sessions
|
||||
/// table — the cwd is passed at runtime via ACP but not persisted
|
||||
/// on its side. This sidecar is how we recover the attribution
|
||||
/// without requiring an upstream schema change.
|
||||
///
|
||||
/// Stored at `~/.hermes/scarf/session_project_map.json`. Forward-
|
||||
/// compatible: if Hermes ever gains a canonical `cwd` column, Scarf
|
||||
/// can prefer that and fall back to this file for pre-upgrade
|
||||
/// sessions. Missing file → empty map (nothing attributed yet).
|
||||
struct SessionProjectMap: Codable, Sendable {
|
||||
/// session-id → absolute-project-path. Both strings are opaque
|
||||
/// from this file's perspective; the service validates project
|
||||
/// paths against the live registry when building the reverse
|
||||
/// lookup used by the Sessions tab, so stale entries for
|
||||
/// removed projects are ignored at read time without needing a
|
||||
/// write-side cleanup.
|
||||
var mappings: [String: String]
|
||||
|
||||
/// ISO-8601 timestamp of the most recent write. Informational
|
||||
/// only — not used for any decision logic. Useful when debugging
|
||||
/// a stale sidecar ("when was this last updated?").
|
||||
var updatedAt: String?
|
||||
|
||||
init(mappings: [String: String] = [:], updatedAt: String? = nil) {
|
||||
self.mappings = mappings
|
||||
self.updatedAt = updatedAt
|
||||
}
|
||||
|
||||
/// Current time in ISO-8601 format, suitable for the
|
||||
/// `updatedAt` field. Matches the format used elsewhere in
|
||||
/// Scarf (e.g. `TemplateLock.installedAt`) so tooling that
|
||||
/// greps across .json files sees consistent timestamps.
|
||||
static func nowISO8601() -> String {
|
||||
ISO8601DateFormatter().string(from: Date())
|
||||
}
|
||||
}
|
||||
@@ -212,6 +212,16 @@ struct HermesFileService: Sendable {
|
||||
replyPrefix: str("whatsapp.reply_prefix")
|
||||
)
|
||||
|
||||
// `platform_toolsets.<platform>` is a dict of lists in config.yaml —
|
||||
// parseNestedYAML flattens nested lists into dotted-path keys. Pull
|
||||
// every key under the prefix and strip it.
|
||||
var platformToolsets: [String: [String]] = [:]
|
||||
for (key, items) in lists where key.hasPrefix("platform_toolsets.") {
|
||||
let platform = String(key.dropFirst("platform_toolsets.".count))
|
||||
guard !platform.isEmpty else { continue }
|
||||
platformToolsets[platform] = items
|
||||
}
|
||||
|
||||
// Home Assistant lives under `platforms.homeassistant.extra.*`.
|
||||
let homeAssistant = HomeAssistantSettings(
|
||||
watchDomains: lists["platforms.homeassistant.extra.watch_domains"] ?? [],
|
||||
@@ -259,6 +269,7 @@ struct HermesFileService: Sendable {
|
||||
cronWrapResponse: bool("cron.wrap_response", default: true),
|
||||
prefillMessagesFile: str("prefill_messages_file"),
|
||||
skillsExternalDirs: lists["skills.external_dirs"] ?? [],
|
||||
platformToolsets: platformToolsets,
|
||||
display: display,
|
||||
terminal: terminal,
|
||||
browser: browser,
|
||||
|
||||
@@ -36,6 +36,16 @@ final class HermesFileWatcher {
|
||||
paths.errorsLog,
|
||||
paths.gatewayLog,
|
||||
paths.projectsRegistry,
|
||||
// v2.3: sidecar attributing Hermes session IDs to Scarf project
|
||||
// paths. Written by SessionAttributionService when a chat
|
||||
// starts with a project context; read by
|
||||
// ProjectSessionsViewModel to filter the session list. Without
|
||||
// watching this file, the per-project Sessions tab would only
|
||||
// pick up new sessions when the user re-entered the tab
|
||||
// (triggering .task(id:) re-fire) — switching directly back
|
||||
// to the project's Sessions tab after a chat left the tab
|
||||
// stale.
|
||||
paths.sessionProjectMap,
|
||||
paths.mcpTokensDir
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
import Foundation
|
||||
import AppKit
|
||||
import os
|
||||
import ScarfCore
|
||||
|
||||
/// Drives `hermes auth add nous --no-browser` for Nous Portal sign-in.
|
||||
///
|
||||
/// Nous uses OAuth 2.0 device-code flow, not PKCE. Hermes prints the
|
||||
/// verification URL + user code to stdout, then long-polls the token
|
||||
/// endpoint every ~1s until the user approves in their browser (or the
|
||||
/// device code expires, currently 15 minutes).
|
||||
///
|
||||
/// The controller:
|
||||
///
|
||||
/// 1. Spawns hermes via `context.makeTransport().makeProcess(...)`.
|
||||
/// 2. Streams stdout, regex-extracts the `verification_uri_complete` and
|
||||
/// `user_code` from the lines hermes prints (auth.py:3282-3286).
|
||||
/// 3. Auto-opens the verification URL in the default browser and
|
||||
/// transitions to `.waitingForApproval` so the sheet can show the code.
|
||||
/// 4. On subprocess exit, confirms success by re-reading `auth.json` via
|
||||
/// `NousSubscriptionService` — hermes exit 0 alone isn't enough, we want
|
||||
/// to see `providers.nous.access_token` actually landed.
|
||||
/// 5. Detects the `subscription_required` failure (auth.py:3347-3356) and
|
||||
/// surfaces the billing URL so the sheet can offer a Subscribe link.
|
||||
///
|
||||
/// The parser functions are `nonisolated static` so tests can feed fixture
|
||||
/// buffers without standing up a real subprocess.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class NousAuthFlow {
|
||||
enum State: Equatable {
|
||||
case idle
|
||||
case starting
|
||||
case waitingForApproval(userCode: String, verificationURL: URL)
|
||||
case success
|
||||
case failure(reason: String, billingURL: URL?)
|
||||
}
|
||||
|
||||
private(set) var state: State = .idle
|
||||
/// Accumulated subprocess output. Surfaced in the failure UI so the user
|
||||
/// can copy the tail for bug reports.
|
||||
private(set) var output: String = ""
|
||||
|
||||
let context: ServerContext
|
||||
private let subscriptionService: NousSubscriptionService
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "NousAuthFlow")
|
||||
|
||||
private var process: Process?
|
||||
private var stdoutPipe: Pipe?
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.subscriptionService = NousSubscriptionService(context: context)
|
||||
}
|
||||
|
||||
// MARK: - Lifecycle
|
||||
|
||||
/// Start the sign-in flow. Any in-flight subprocess is terminated first.
|
||||
/// Safe to call repeatedly (e.g. user hits "Try again").
|
||||
func start() {
|
||||
cancel()
|
||||
output = ""
|
||||
state = .starting
|
||||
|
||||
let proc = context.makeTransport().makeProcess(
|
||||
executable: context.paths.hermesBinary,
|
||||
args: ["auth", "add", "nous", "--no-browser"]
|
||||
)
|
||||
if !context.isRemote {
|
||||
// Only enrich env locally — remote ssh gets the remote login env
|
||||
// naturally, and exporting our local keys into it would be wrong.
|
||||
var env = HermesFileService.enrichedEnvironment()
|
||||
// Python block-buffers stdout when it's a pipe (not a TTY). The
|
||||
// device-code flow prints the verification URL + user code, then
|
||||
// enters a ~15-minute polling loop that never hits `input()` —
|
||||
// so nothing flushes and our readability handler never sees the
|
||||
// output. Users see the sheet spinning forever while hermes is
|
||||
// actually waiting for approval.
|
||||
//
|
||||
// PKCE doesn't have this problem because `input("Authorization
|
||||
// code: ")` flushes stdout before blocking, which is why
|
||||
// OAuthFlowController works without this setting.
|
||||
//
|
||||
// PYTHONUNBUFFERED forces line-buffered stdout for the whole
|
||||
// subprocess — tiny perf cost, huge UX win for device-code.
|
||||
env["PYTHONUNBUFFERED"] = "1"
|
||||
proc.environment = env
|
||||
}
|
||||
|
||||
let outPipe = Pipe()
|
||||
// Merge stderr into stdout — hermes prints the device-code block to
|
||||
// stdout but may emit diagnostics on stderr; we want them interleaved
|
||||
// in display order so the failure-tail UI reads naturally.
|
||||
proc.standardOutput = outPipe
|
||||
proc.standardError = outPipe
|
||||
|
||||
outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in
|
||||
let data = handle.availableData
|
||||
if data.isEmpty {
|
||||
handle.readabilityHandler = nil
|
||||
return
|
||||
}
|
||||
let chunk = String(data: data, encoding: .utf8) ?? ""
|
||||
Task { @MainActor [weak self] in
|
||||
self?.handleOutputChunk(chunk)
|
||||
}
|
||||
}
|
||||
|
||||
proc.terminationHandler = { [weak self] p in
|
||||
let code = p.terminationStatus
|
||||
Task { @MainActor [weak self] in
|
||||
outPipe.fileHandleForReading.readabilityHandler = nil
|
||||
self?.handleTermination(exitCode: code)
|
||||
}
|
||||
}
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
process = proc
|
||||
stdoutPipe = outPipe
|
||||
} catch {
|
||||
logger.error("failed to spawn hermes: \(error.localizedDescription, privacy: .public)")
|
||||
state = .failure(
|
||||
reason: "Failed to start hermes: \(error.localizedDescription)",
|
||||
billingURL: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Terminate the in-flight subprocess. Idempotent. Does NOT clear state —
|
||||
/// the sheet dismisses on cancel via its own binding, and re-opening
|
||||
/// calls `start()` which does a fresh reset.
|
||||
func cancel() {
|
||||
stdoutPipe?.fileHandleForReading.readabilityHandler = nil
|
||||
process?.terminate()
|
||||
process = nil
|
||||
stdoutPipe = nil
|
||||
}
|
||||
|
||||
// MARK: - Output handling
|
||||
|
||||
private func handleOutputChunk(_ chunk: String) {
|
||||
output += chunk
|
||||
// Only transition into waiting while we're still in .starting — once
|
||||
// we've already emitted the URL + code, subsequent "Waiting for
|
||||
// approval..." noise shouldn't re-fire NSWorkspace.open.
|
||||
guard case .starting = state else { return }
|
||||
if let result = Self.parseDeviceCode(from: output) {
|
||||
state = .waitingForApproval(
|
||||
userCode: result.userCode,
|
||||
verificationURL: result.verificationURL
|
||||
)
|
||||
NSWorkspace.shared.open(result.verificationURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func handleTermination(exitCode: Int32) {
|
||||
// Subscription-required is a specific failure path that hermes
|
||||
// signals both via an exit code and a unique billing-URL message.
|
||||
// It overrides other checks because we want the Subscribe affordance
|
||||
// in the UI regardless of exit code.
|
||||
if let billing = Self.parseSubscriptionRequired(from: output) {
|
||||
state = .failure(
|
||||
reason: "Your Nous Portal account does not have an active subscription.",
|
||||
billingURL: billing
|
||||
)
|
||||
return
|
||||
}
|
||||
if exitCode == 0 {
|
||||
// Hermes claims success. Confirm by reading auth.json — the
|
||||
// authoritative signal is that providers.nous has an access token
|
||||
// AND active_provider flipped to nous. Anything short of that is
|
||||
// a silent failure on the hermes side.
|
||||
let sub = subscriptionService.loadState()
|
||||
if sub.subscribed {
|
||||
state = .success
|
||||
} else if sub.present {
|
||||
state = .failure(
|
||||
reason: "Signed in, but Nous isn't the active provider yet. Run `hermes model` and pick Nous Portal.",
|
||||
billingURL: nil
|
||||
)
|
||||
} else {
|
||||
state = .failure(
|
||||
reason: "Sign-in finished without writing credentials. Try again, or run `hermes auth add nous` in a terminal to see full diagnostics.",
|
||||
billingURL: nil
|
||||
)
|
||||
}
|
||||
} else {
|
||||
let tail = Self.lastLines(of: output, count: 8)
|
||||
state = .failure(
|
||||
reason: tail.isEmpty
|
||||
? "hermes exited with code \(exitCode)"
|
||||
: tail,
|
||||
billingURL: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Parsers (pure, testable)
|
||||
|
||||
struct DeviceCodeResult: Equatable {
|
||||
let verificationURL: URL
|
||||
let userCode: String
|
||||
}
|
||||
|
||||
/// Extract the device-code verification URL and user code from hermes's
|
||||
/// output. Anchored on the exact shape hermes prints (auth.py:3282-3286):
|
||||
///
|
||||
/// To continue:
|
||||
/// 1. Open: https://portal.nousresearch.com/device/XXXX-XXXX
|
||||
/// 2. If prompted, enter code: XXXX-XXXX
|
||||
///
|
||||
/// Returns nil when either line is missing — the sheet stays on the
|
||||
/// `.starting` spinner until both are captured.
|
||||
nonisolated static func parseDeviceCode(from text: String) -> DeviceCodeResult? {
|
||||
let urlPattern = #"^\s*1\.\s*Open:\s*(https?://\S+)\s*$"#
|
||||
let codePattern = #"^\s*2\.\s*If prompted, enter code:\s*(\S+)\s*$"#
|
||||
guard
|
||||
let urlString = firstCapture(in: text, pattern: urlPattern),
|
||||
let userCode = firstCapture(in: text, pattern: codePattern),
|
||||
let url = URL(string: urlString)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return DeviceCodeResult(verificationURL: url, userCode: userCode)
|
||||
}
|
||||
|
||||
/// Detect the subscription-required failure and extract the billing URL
|
||||
/// hermes prints (auth.py:3347-3356). Scarf shows a "Subscribe" button
|
||||
/// linking to this URL so the user can resolve the blocker without
|
||||
/// hunting through logs.
|
||||
nonisolated static func parseSubscriptionRequired(from text: String) -> URL? {
|
||||
guard text.contains("Your Nous Portal account does not have an active subscription") else {
|
||||
return nil
|
||||
}
|
||||
guard
|
||||
let raw = firstCapture(in: text, pattern: #"Subscribe here:\s*(https?://\S+)"#),
|
||||
let url = URL(string: raw)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
private nonisolated static func firstCapture(in text: String, pattern: String) -> String? {
|
||||
guard let regex = try? NSRegularExpression(pattern: pattern, options: [.anchorsMatchLines]) else {
|
||||
return nil
|
||||
}
|
||||
let range = NSRange(text.startIndex..., in: text)
|
||||
guard
|
||||
let match = regex.firstMatch(in: text, range: range),
|
||||
match.numberOfRanges >= 2,
|
||||
let r = Range(match.range(at: 1), in: text)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
return String(text[r])
|
||||
}
|
||||
|
||||
private nonisolated static func lastLines(of text: String, count: Int) -> String {
|
||||
let lines = text.split(separator: "\n", omittingEmptySubsequences: false)
|
||||
return lines.suffix(count).joined(separator: "\n")
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Foundation
|
||||
import os
|
||||
import ScarfCore
|
||||
|
||||
/// Snapshot of the user's Nous Portal subscription state, derived from the
|
||||
/// `providers.nous` entry in `~/.hermes/auth.json`. Read-only — Scarf never
|
||||
/// writes the subscription record; `hermes model` + `hermes auth` own that
|
||||
/// path.
|
||||
struct NousSubscriptionState: Sendable, Hashable {
|
||||
/// True when `providers.nous` exists and has a usable access token.
|
||||
/// Mirrors the `nous_auth_present` field on
|
||||
/// `NousSubscriptionFeatures` in `hermes_cli/nous_subscription.py`.
|
||||
let present: Bool
|
||||
/// True when the user's **active provider** is `nous`, i.e. they've not
|
||||
/// just authed but selected it as the primary model provider. The Tool
|
||||
/// Gateway only routes tools when this is true — auth alone isn't enough.
|
||||
let providerIsNous: Bool
|
||||
/// Last update time for the auth record, if known. Useful in the Health
|
||||
/// view to tell the user when their subscription state was last refreshed.
|
||||
let updatedAt: Date?
|
||||
|
||||
static let absent = NousSubscriptionState(present: false, providerIsNous: false, updatedAt: nil)
|
||||
|
||||
/// Overall subscription active for Tool Gateway routing. Both halves have
|
||||
/// to line up: auth record present *and* `nous` is the active provider.
|
||||
/// Mirrors `NousSubscriptionFeatures.subscribed` on the Python side.
|
||||
var subscribed: Bool { present && providerIsNous }
|
||||
}
|
||||
|
||||
/// Reads `auth.json` to detect Nous Portal subscription state. Delegates file
|
||||
/// I/O to the active `ServerTransport`, so remote installations work the same
|
||||
/// as local ones.
|
||||
///
|
||||
/// The auth-record shape is defined by hermes-agent and is load-bearing. This
|
||||
/// service parses a small, stable subset and tolerates anything new Hermes
|
||||
/// adds — we only rely on `providers.nous` being a dict with `access_token`
|
||||
/// and `active_provider` being either `"nous"` or not.
|
||||
struct NousSubscriptionService: Sendable {
|
||||
private let logger = Logger(subsystem: "com.scarf", category: "NousSubscriptionService")
|
||||
let authJSONPath: String
|
||||
let transport: any ServerTransport
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.authJSONPath = context.paths.authJSON
|
||||
self.transport = context.makeTransport()
|
||||
}
|
||||
|
||||
/// Escape hatch for tests — point at a fixture `auth.json` without
|
||||
/// constructing a full `ServerContext`. Uses `LocalTransport` so the
|
||||
/// fixture must live on the local filesystem.
|
||||
init(path: String) {
|
||||
self.authJSONPath = path
|
||||
self.transport = LocalTransport()
|
||||
}
|
||||
|
||||
/// Load the current subscription state. Returns ``NousSubscriptionState/absent``
|
||||
/// on any read or parse failure — callers treat "absent" and "can't
|
||||
/// read" the same in UI (show a "not subscribed" CTA).
|
||||
nonisolated func loadState() -> NousSubscriptionState {
|
||||
guard let data = try? transport.readFile(authJSONPath) else {
|
||||
return .absent
|
||||
}
|
||||
guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
||||
logger.warning("auth.json is not a JSON object; assuming no Nous subscription")
|
||||
return .absent
|
||||
}
|
||||
let providers = root["providers"] as? [String: Any] ?? [:]
|
||||
let nous = providers["nous"] as? [String: Any]
|
||||
let token = nous?["access_token"] as? String
|
||||
let present = (token?.isEmpty == false)
|
||||
|
||||
let activeProvider = root["active_provider"] as? String
|
||||
let providerIsNous = (activeProvider == "nous")
|
||||
|
||||
let updatedAt: Date? = {
|
||||
guard let raw = root["updated_at"] as? String else { return nil }
|
||||
return ISO8601DateFormatter().date(from: raw)
|
||||
}()
|
||||
|
||||
return NousSubscriptionState(
|
||||
present: present,
|
||||
providerIsNous: providerIsNous,
|
||||
updatedAt: updatedAt
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,294 @@
|
||||
import Foundation
|
||||
import os
|
||||
import ScarfCore
|
||||
|
||||
/// Writes a Scarf-managed marker block into `<project>/AGENTS.md` so
|
||||
/// that Hermes — which auto-reads `AGENTS.md` from the session's cwd
|
||||
/// at startup — has consistent project identity and metadata in every
|
||||
/// project-scoped chat.
|
||||
///
|
||||
/// **Why this exists.** Hermes has no native "project" concept and ACP
|
||||
/// passes only `(cwd, mcpServers)` at session create — extra params
|
||||
/// are silently dropped on Hermes's side. The documented hook for
|
||||
/// giving the agent context when cwd is set programmatically is the
|
||||
/// auto-load of `AGENTS.md` (or `.hermes.md` / `CLAUDE.md` /
|
||||
/// `.cursorrules`, in that priority) from the cwd. Scarf owns a
|
||||
/// managed region of the project's AGENTS.md; template-author content
|
||||
/// lives outside that region and is preserved.
|
||||
///
|
||||
/// **Marker contract.** The region sits between:
|
||||
///
|
||||
/// ```
|
||||
/// <!-- scarf-project:begin -->
|
||||
/// …Scarf-managed content…
|
||||
/// <!-- scarf-project:end -->
|
||||
/// ```
|
||||
///
|
||||
/// Same pattern as the v2.2 memory-block appendix — bounded, self-
|
||||
/// declaring, safe to re-generate. Everything outside the markers is
|
||||
/// left byte-identical across refreshes.
|
||||
///
|
||||
/// **Secret-safe.** The block surfaces field NAMES from `config.json`
|
||||
/// (via the cached manifest's schema) but never VALUES. A rendered
|
||||
/// block contains no secrets even for a project whose config.json
|
||||
/// has Keychain-ref URIs.
|
||||
///
|
||||
/// **Refresh timing.** `ChatViewModel.startACPSession(resume:projectPath:)`
|
||||
/// calls `refresh(for:)` immediately before Hermes opens the session.
|
||||
/// Hermes reads AGENTS.md during session boot, so the marker block
|
||||
/// must have landed on disk first. Non-blocking on failure — a
|
||||
/// failed refresh logs and the chat proceeds without the block.
|
||||
struct ProjectAgentContextService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectAgentContextService")
|
||||
|
||||
/// Marker strings. Load-bearing: the format must stay stable
|
||||
/// across releases so existing project AGENTS.md files continue
|
||||
/// to be recognized and rewritten cleanly.
|
||||
static let beginMarker = "<!-- scarf-project:begin -->"
|
||||
static let endMarker = "<!-- scarf-project:end -->"
|
||||
|
||||
let context: ServerContext
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
// MARK: - Public
|
||||
|
||||
/// Refresh (or create) the Scarf-managed block in the project's
|
||||
/// AGENTS.md. Reads current project state — template manifest,
|
||||
/// config schema, registered cron jobs — and produces a block
|
||||
/// reflecting today's truth. Idempotent: two consecutive calls
|
||||
/// with no intervening state change produce byte-identical
|
||||
/// output.
|
||||
nonisolated func refresh(for project: ProjectEntry) throws {
|
||||
let block = renderBlock(for: project)
|
||||
let path = agentsMdPath(for: project)
|
||||
let transport = context.makeTransport()
|
||||
|
||||
// Ensure the project directory exists — this service is the
|
||||
// first thing that touches the project dir when the user
|
||||
// scaffolds a bare project via `+` + starts a chat. Normally
|
||||
// the dir exists (registered project = dir exists); belt-
|
||||
// and-suspenders for edge cases.
|
||||
if !transport.fileExists(project.path) {
|
||||
try transport.createDirectory(project.path)
|
||||
}
|
||||
|
||||
if !transport.fileExists(path) {
|
||||
// Fresh AGENTS.md with just our block + a trailing
|
||||
// newline so editors render it cleanly.
|
||||
let data = (block + "\n").data(using: .utf8) ?? Data()
|
||||
try transport.writeFile(path, data: data)
|
||||
Self.logger.info("created AGENTS.md with Scarf block for \(project.name, privacy: .public)")
|
||||
return
|
||||
}
|
||||
|
||||
// Read existing, splice in the new block.
|
||||
let existingData = try transport.readFile(path)
|
||||
let existing = String(data: existingData, encoding: .utf8) ?? ""
|
||||
let rewritten = Self.applyBlock(block: block, to: existing)
|
||||
guard let outData = rewritten.data(using: .utf8) else {
|
||||
throw ProjectAgentContextError.encodingFailed
|
||||
}
|
||||
// Skip the write when nothing changed — avoids unnecessary
|
||||
// file-watcher churn. Matches what disk snapshot shows.
|
||||
guard outData != existingData else { return }
|
||||
try transport.writeFile(path, data: outData)
|
||||
Self.logger.info("refreshed Scarf block in AGENTS.md for \(project.name, privacy: .public)")
|
||||
}
|
||||
|
||||
// MARK: - Marker splice (testable in isolation)
|
||||
|
||||
/// Core text transform: given an existing file and a freshly-
|
||||
/// rendered block, return the file with the block spliced in.
|
||||
///
|
||||
/// Three cases handled:
|
||||
/// 1. Existing file has both markers → replace the inclusive
|
||||
/// region, preserve everything outside untouched.
|
||||
/// 2. Existing file has no markers → prepend the block followed
|
||||
/// by a two-newline separator so it reads as its own section.
|
||||
/// 3. Existing file has a begin marker but no end → we DON'T try
|
||||
/// to be clever; treat as "no markers present" and prepend.
|
||||
/// User intervention or a later refresh can restore shape.
|
||||
/// The stray begin-marker is left in the file; we don't
|
||||
/// truncate to EOF (as the memory-block installer does)
|
||||
/// because an orphaned begin on this file is more likely
|
||||
/// hand-typed than a corrupt Scarf write.
|
||||
nonisolated static func applyBlock(block: String, to existing: String) -> String {
|
||||
guard let beginRange = existing.range(of: beginMarker),
|
||||
let endRange = existing.range(
|
||||
of: endMarker,
|
||||
range: beginRange.upperBound..<existing.endIndex
|
||||
)
|
||||
else {
|
||||
// No well-formed Scarf block present — prepend.
|
||||
let trimmedExisting = existing.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmedExisting.isEmpty {
|
||||
return block + "\n"
|
||||
}
|
||||
return block + "\n\n" + existing
|
||||
}
|
||||
// Full span: from the begin marker through the end marker
|
||||
// (inclusive). Consumes any trailing whitespace/newlines
|
||||
// immediately following the end marker so a re-render of a
|
||||
// shorter block doesn't leave a dangling blank line.
|
||||
var upperBound = endRange.upperBound
|
||||
while upperBound < existing.endIndex,
|
||||
existing[upperBound].isNewline {
|
||||
upperBound = existing.index(after: upperBound)
|
||||
}
|
||||
let before = String(existing[existing.startIndex..<beginRange.lowerBound])
|
||||
let after = String(existing[upperBound..<existing.endIndex])
|
||||
// Preserve the leading whitespace / content structure of
|
||||
// `before` but ensure exactly one blank line separates it
|
||||
// from the new block when there IS prior content.
|
||||
let prefix = before.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? ""
|
||||
: before.trimmingRightNewlines() + "\n\n"
|
||||
// Suffix: a blank line BEFORE the remaining content, ensuring
|
||||
// the template/user content is visually separated from the
|
||||
// Scarf block.
|
||||
let suffix = after.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
? "\n"
|
||||
: "\n\n" + after.trimmingLeftNewlines()
|
||||
return prefix + block + suffix
|
||||
}
|
||||
|
||||
// MARK: - Block rendering
|
||||
|
||||
/// Build the Markdown block for a given project. Pure function of
|
||||
/// project state — exposed for tests that want to assert on
|
||||
/// rendered content without touching disk.
|
||||
nonisolated func renderBlock(for project: ProjectEntry) -> String {
|
||||
let templateInfo = readTemplateInfo(for: project)
|
||||
let configFieldsLine = renderConfigFieldsLine(for: project)
|
||||
let cronLines = renderCronLines(for: project, templateId: templateInfo?.id)
|
||||
let lockFilePresent = context.makeTransport().fileExists(
|
||||
project.path + "/.scarf/template.lock.json"
|
||||
)
|
||||
|
||||
var lines: [String] = []
|
||||
lines.append(Self.beginMarker)
|
||||
lines.append("## Scarf project context")
|
||||
lines.append("")
|
||||
lines.append("_Auto-generated by Scarf — do not edit between the begin/end markers._")
|
||||
lines.append("")
|
||||
lines.append("You are operating inside a Scarf project named **\"\(project.name)\"**. Scarf is a macOS GUI for Hermes; the user is working with this project through it. This chat session's working directory is the project's directory — path-relative tool calls resolve inside the project.")
|
||||
lines.append("")
|
||||
lines.append("- **Project directory:** `\(project.path)`")
|
||||
lines.append("- **Dashboard:** `\(project.path)/.scarf/dashboard.json`")
|
||||
|
||||
if let tpl = templateInfo {
|
||||
lines.append("- **Template:** `\(tpl.id)` v\(tpl.version)")
|
||||
}
|
||||
lines.append("- **Configuration fields:** \(configFieldsLine)")
|
||||
|
||||
if cronLines.isEmpty {
|
||||
lines.append("- **Registered cron jobs:** (none attributed to this project)")
|
||||
} else {
|
||||
lines.append("- **Registered cron jobs:**")
|
||||
for line in cronLines {
|
||||
lines.append(" - \(line)")
|
||||
}
|
||||
}
|
||||
|
||||
if lockFilePresent {
|
||||
lines.append("- **Uninstall manifest:** `\(project.path)/.scarf/template.lock.json` (tracks files written by template install)")
|
||||
}
|
||||
|
||||
lines.append("")
|
||||
lines.append("Any content below this block is template- or user-authored; preserve and defer to it for project-specific behavior. Do NOT modify content inside these markers — Scarf rewrites this block on every project-scoped chat start.")
|
||||
lines.append(Self.endMarker)
|
||||
|
||||
return lines.joined(separator: "\n")
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
nonisolated private func agentsMdPath(for project: ProjectEntry) -> String {
|
||||
project.path + "/AGENTS.md"
|
||||
}
|
||||
|
||||
/// Read `<project>/.scarf/manifest.json` for template id + version.
|
||||
/// Nil when not present (bare project) or when the file is
|
||||
/// unparseable — the block still renders cleanly without the
|
||||
/// template line.
|
||||
nonisolated private func readTemplateInfo(for project: ProjectEntry) -> (id: String, version: String)? {
|
||||
let manifestPath = project.path + "/.scarf/manifest.json"
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(manifestPath) else { return nil }
|
||||
guard let data = try? transport.readFile(manifestPath) else { return nil }
|
||||
guard let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data) else { return nil }
|
||||
return (id: manifest.id, version: manifest.version)
|
||||
}
|
||||
|
||||
/// Build the "Configuration fields" bullet's tail. Returns a
|
||||
/// comma-joined list of backticked field names with inline type
|
||||
/// hints (`(secret)`), or the literal string "(none)" when the
|
||||
/// project has no config schema. **Never** includes values.
|
||||
nonisolated private func renderConfigFieldsLine(for project: ProjectEntry) -> String {
|
||||
let manifestPath = project.path + "/.scarf/manifest.json"
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(manifestPath),
|
||||
let data = try? transport.readFile(manifestPath),
|
||||
let manifest = try? JSONDecoder().decode(ProjectTemplateManifest.self, from: data),
|
||||
let schema = manifest.config,
|
||||
!schema.fields.isEmpty
|
||||
else {
|
||||
return "(none)"
|
||||
}
|
||||
let fieldList = schema.fields.map { field -> String in
|
||||
let secretTag = field.type == .secret ? " (secret — name only, value stored in Keychain)" : ""
|
||||
return "`\(field.key)`\(secretTag)"
|
||||
}
|
||||
return fieldList.joined(separator: ", ")
|
||||
}
|
||||
|
||||
/// Return a list of human-readable cron-job descriptions for jobs
|
||||
/// attributed to this project via the `[tmpl:<id>] …` name prefix.
|
||||
/// Empty array when no jobs match (either the project has no
|
||||
/// template or no jobs carry the tag).
|
||||
nonisolated private func renderCronLines(for project: ProjectEntry, templateId: String?) -> [String] {
|
||||
guard let templateId else { return [] }
|
||||
let prefix = "[tmpl:\(templateId)]"
|
||||
let jobs = HermesFileService(context: context).loadCronJobs()
|
||||
return jobs
|
||||
.filter { $0.name.hasPrefix(prefix) }
|
||||
.map { job in
|
||||
let scheduleDesc = job.schedule.display
|
||||
?? job.schedule.expression
|
||||
?? job.schedule.kind
|
||||
let pausedDesc = job.enabled ? "enabled" : "paused"
|
||||
return "`\(job.name)` — schedule `\(scheduleDesc)`, currently \(pausedDesc)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum ProjectAgentContextError: Error {
|
||||
case encodingFailed
|
||||
}
|
||||
|
||||
// MARK: - String helpers (file-scoped)
|
||||
|
||||
private extension String {
|
||||
/// Drop trailing newlines + CRs but preserve other trailing
|
||||
/// whitespace (tabs, non-breaking spaces) that might be
|
||||
/// meaningful in some edge case.
|
||||
func trimmingRightNewlines() -> String {
|
||||
var result = self
|
||||
while let last = result.last, last.isNewline {
|
||||
result.removeLast()
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/// Symmetric counterpart: strip leading newlines / CRs.
|
||||
func trimmingLeftNewlines() -> String {
|
||||
var result = self
|
||||
while let first = result.first, first.isNewline {
|
||||
result.removeFirst()
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import Foundation
|
||||
import os
|
||||
import ScarfCore
|
||||
|
||||
/// Owns the sidecar that attributes Hermes session IDs to Scarf
|
||||
/// project paths. The `cwd` passed to `hermes acp` at session
|
||||
/// creation is ephemeral from Hermes's perspective (not written to
|
||||
/// `state.db`), so Scarf keeps this Scarf-owned record parallel to
|
||||
/// Hermes's session store.
|
||||
///
|
||||
/// File: `~/.hermes/scarf/session_project_map.json` (resolved via
|
||||
/// `HermesPathSet.sessionProjectMap`).
|
||||
///
|
||||
/// Thread safety: all public methods are `nonisolated` and each
|
||||
/// performs a single read-modify-write cycle that's atomic on
|
||||
/// disk. Concurrent writers (two Scarf windows on the same
|
||||
/// `~/.hermes`) are safe at the file level — last write wins —
|
||||
/// but the in-memory read in one window may lag until that window
|
||||
/// reloads. Acceptable for v2.3's scale; revisit if multi-window
|
||||
/// cross-talk becomes a problem.
|
||||
struct SessionAttributionService: Sendable {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "SessionAttributionService")
|
||||
|
||||
let context: ServerContext
|
||||
|
||||
nonisolated init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
}
|
||||
|
||||
// MARK: - Read
|
||||
|
||||
/// Load the current sidecar contents. Missing file or unparseable
|
||||
/// JSON returns an empty map — the sidecar is a convenience
|
||||
/// index, not a source of truth for anything load-bearing.
|
||||
nonisolated func load() -> SessionProjectMap {
|
||||
let path = context.paths.sessionProjectMap
|
||||
let transport = context.makeTransport()
|
||||
guard transport.fileExists(path) else {
|
||||
return SessionProjectMap()
|
||||
}
|
||||
do {
|
||||
let data = try transport.readFile(path)
|
||||
return try JSONDecoder().decode(SessionProjectMap.self, from: data)
|
||||
} catch {
|
||||
Self.logger.warning("session-project-map parse failed at \(path, privacy: .public): \(error.localizedDescription, privacy: .public); returning empty map")
|
||||
return SessionProjectMap()
|
||||
}
|
||||
}
|
||||
|
||||
/// Look up the project path a given session was attributed to.
|
||||
/// Returns nil for unattributed sessions (CLI-started, or
|
||||
/// started before v2.3) — those surface in the global Sessions
|
||||
/// sidebar unchanged and don't appear in any project's Sessions
|
||||
/// tab.
|
||||
nonisolated func projectPath(for sessionID: String) -> String? {
|
||||
load().mappings[sessionID]
|
||||
}
|
||||
|
||||
/// Reverse lookup: every session ID attributed to the given
|
||||
/// project path. Used by the per-project Sessions tab to filter
|
||||
/// the global session list. Comparison is exact-string; the
|
||||
/// registry stores absolute paths and we write absolute paths,
|
||||
/// so no normalisation is needed in practice.
|
||||
nonisolated func sessionIDs(forProject projectPath: String) -> Set<String> {
|
||||
let map = load()
|
||||
return Set(map.mappings.filter { $0.value == projectPath }.keys)
|
||||
}
|
||||
|
||||
// MARK: - Write
|
||||
|
||||
/// Record that `sessionID` was created under the given project
|
||||
/// path. Idempotent — repeated calls for the same pair are no-
|
||||
/// ops. Replacing an existing mapping (session moved to a
|
||||
/// different project) is legal but expected to be rare; the
|
||||
/// caller decides when that's correct.
|
||||
nonisolated func attribute(sessionID: String, toProjectPath projectPath: String) {
|
||||
var map = load()
|
||||
if map.mappings[sessionID] == projectPath {
|
||||
return
|
||||
}
|
||||
map.mappings[sessionID] = projectPath
|
||||
map.updatedAt = SessionProjectMap.nowISO8601()
|
||||
persist(map)
|
||||
}
|
||||
|
||||
/// Remove a mapping. Called in v2.3's Sessions-tab code path is
|
||||
/// minimal — we don't currently prune on session delete because
|
||||
/// Hermes owns session lifecycle and we don't observe deletes.
|
||||
/// Exposed for future roadmap items (e.g. explicit "detach
|
||||
/// from project" action) and tests.
|
||||
nonisolated func forget(sessionID: String) {
|
||||
var map = load()
|
||||
guard map.mappings.removeValue(forKey: sessionID) != nil else { return }
|
||||
map.updatedAt = SessionProjectMap.nowISO8601()
|
||||
persist(map)
|
||||
}
|
||||
|
||||
// MARK: - Private
|
||||
|
||||
private func persist(_ map: SessionProjectMap) {
|
||||
let path = context.paths.sessionProjectMap
|
||||
let transport = context.makeTransport()
|
||||
let dir = context.paths.scarfDir
|
||||
do {
|
||||
if !transport.fileExists(dir) {
|
||||
try transport.createDirectory(dir)
|
||||
}
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
|
||||
let data = try encoder.encode(map)
|
||||
try transport.writeFile(path, data: data)
|
||||
} catch {
|
||||
Self.logger.error("failed to persist session-project-map at \(path, privacy: .public): \(error.localizedDescription, privacy: .public)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,20 @@ final class ChatViewModel {
|
||||
let richChatViewModel: RichChatViewModel
|
||||
private var coordinator: Coordinator?
|
||||
|
||||
/// Absolute project path for the current session, when the chat is
|
||||
/// project-scoped (either started via a project's "New Chat" button
|
||||
/// or resumed from a session that was previously attributed via the
|
||||
/// v2.3 sidecar). Nil for plain global chats. Drives the project
|
||||
/// indicator in SessionInfoBar + the `Chat · <Name>` nav title.
|
||||
private(set) var currentProjectPath: String?
|
||||
|
||||
/// Human-readable name of the active project, resolved from the
|
||||
/// projects registry at session-start time. Stored alongside the
|
||||
/// path so the view renders without hitting disk on every update.
|
||||
/// Nil when `currentProjectPath` is nil OR the path isn't in the
|
||||
/// registry (project was removed after the session was attributed).
|
||||
private(set) var currentProjectName: String?
|
||||
|
||||
// ACP state
|
||||
private var acpClient: ACPClient?
|
||||
private var acpEventTask: Task<Void, Never>?
|
||||
@@ -119,15 +133,20 @@ final class ChatViewModel {
|
||||
|
||||
// MARK: - Session Lifecycle
|
||||
|
||||
func startNewSession() {
|
||||
func startNewSession(projectPath: String? = nil) {
|
||||
voiceEnabled = false
|
||||
ttsEnabled = false
|
||||
isRecording = false
|
||||
richChatViewModel.reset()
|
||||
|
||||
if displayMode == .richChat {
|
||||
startACPSession(resume: nil)
|
||||
startACPSession(resume: nil, projectPath: projectPath)
|
||||
} else {
|
||||
// Terminal mode doesn't surface project attribution today —
|
||||
// `hermes chat` uses the shell's cwd, so starting a terminal
|
||||
// chat from a project button would require changing the
|
||||
// shell's cwd too. Out of scope for v2.3 — Rich Chat is
|
||||
// the primary surface for project-scoped sessions.
|
||||
launchTerminal(arguments: ["chat"])
|
||||
}
|
||||
}
|
||||
@@ -290,13 +309,33 @@ final class ChatViewModel {
|
||||
|
||||
// MARK: - ACP Session Management
|
||||
|
||||
private func startACPSession(resume sessionId: String?) {
|
||||
private func startACPSession(resume sessionId: String?, projectPath: String? = nil) {
|
||||
stopACP()
|
||||
clearACPErrorState()
|
||||
acpStatus = "Starting..."
|
||||
|
||||
let client = ACPClient.forMacApp(context: context)
|
||||
self.acpClient = client
|
||||
let attribution = SessionAttributionService(context: context)
|
||||
|
||||
// If the caller passed a project path, refresh the Scarf-
|
||||
// managed block in the project's AGENTS.md BEFORE starting
|
||||
// ACP — Hermes auto-reads AGENTS.md at session boot, so the
|
||||
// block has to land on disk first. Non-blocking on failure:
|
||||
// we log and proceed without the block. Safe on bare
|
||||
// projects (creates AGENTS.md with just the block); safe on
|
||||
// template-installed projects (splices the block into
|
||||
// existing AGENTS.md without touching template content).
|
||||
if let projectPath {
|
||||
let registry = ProjectDashboardService(context: context).loadRegistry()
|
||||
if let project = registry.projects.first(where: { $0.path == projectPath }) {
|
||||
do {
|
||||
try ProjectAgentContextService(context: context).refresh(for: project)
|
||||
} catch {
|
||||
logger.warning("couldn't refresh project context block for \(project.name): \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Task { @MainActor in
|
||||
do {
|
||||
@@ -306,7 +345,19 @@ final class ChatViewModel {
|
||||
startACPEventLoop(client: client)
|
||||
startHealthMonitor(client: client)
|
||||
|
||||
let cwd = await context.resolvedUserHome()
|
||||
// Project-scoped chats pass the project's absolute path
|
||||
// as cwd so Hermes tool calls and subsequent ACP ops
|
||||
// resolve relative paths against the project's files.
|
||||
// Falls back to the user's home (existing v2.2 behavior)
|
||||
// when the caller didn't request a project scope.
|
||||
// `??` can't wrap an async autoclosure, so we
|
||||
// materialize the fallback with an if-let.
|
||||
let cwd: String
|
||||
if let projectPath {
|
||||
cwd = projectPath
|
||||
} else {
|
||||
cwd = await context.resolvedUserHome()
|
||||
}
|
||||
|
||||
// Mark active BEFORE setting session ID so .task(id:) sees isACPMode=true
|
||||
// and doesn't wipe messages with a DB refresh
|
||||
@@ -335,6 +386,48 @@ final class ChatViewModel {
|
||||
richChatViewModel.setSessionId(resolvedSessionId)
|
||||
acpStatus = "Connected (\(resolvedSessionId.prefix(12)))"
|
||||
|
||||
// Attribute this session to the project it was started
|
||||
// under, so the per-project Sessions tab can surface it
|
||||
// without a user action. No-op when projectPath is nil.
|
||||
// Idempotent: re-attribution of the same pair is free.
|
||||
if let projectPath {
|
||||
attribution.attribute(
|
||||
sessionID: resolvedSessionId,
|
||||
toProjectPath: projectPath
|
||||
)
|
||||
}
|
||||
|
||||
// Resolve which project (if any) this session belongs
|
||||
// to, so SessionInfoBar + nav title can surface it.
|
||||
// Two inputs — use whichever is non-nil:
|
||||
// * `projectPath` — the caller asked for a project
|
||||
// scope (fresh project chat). Just-attributed;
|
||||
// definitely in the sidecar.
|
||||
// * `attribution.projectPath(for: resolvedSessionId)`
|
||||
// — the resumed session was previously attributed.
|
||||
// Covers "click an old project-attributed session
|
||||
// from the global Sessions sidebar / Resume menu"
|
||||
// where projectPath isn't known at the call site.
|
||||
let attributedPath = projectPath
|
||||
?? attribution.projectPath(for: resolvedSessionId)
|
||||
if let path = attributedPath {
|
||||
// Look up a human-readable name from the projects
|
||||
// registry. Missing project (path in the sidecar,
|
||||
// project since removed) → show the path as a
|
||||
// fallback label so the chip still renders and the
|
||||
// user sees *something* rather than silently losing
|
||||
// the indicator.
|
||||
let registry = ProjectDashboardService(context: context).loadRegistry()
|
||||
let name = registry.projects.first(where: { $0.path == path })?.name
|
||||
self.currentProjectPath = path
|
||||
self.currentProjectName = name ?? path
|
||||
} else {
|
||||
// Explicit clear on non-project sessions so the
|
||||
// indicator doesn't leak from a previous chat.
|
||||
self.currentProjectPath = nil
|
||||
self.currentProjectName = nil
|
||||
}
|
||||
|
||||
// Refresh session list so the new ACP session appears in the Resume menu
|
||||
await loadRecentSessions()
|
||||
|
||||
|
||||
@@ -4,25 +4,83 @@ import ScarfCore
|
||||
struct ChatView: View {
|
||||
@Environment(ChatViewModel.self) private var viewModel
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@State private var showErrorDetails = false
|
||||
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
@Bindable var coord = coordinator
|
||||
VStack(spacing: 0) {
|
||||
toolbar
|
||||
Divider()
|
||||
errorBanner
|
||||
chatArea
|
||||
}
|
||||
.navigationTitle("Chat")
|
||||
// Clamp the outer VStack to the detail column's offered
|
||||
// space. Without this, the chat area's intrinsic height (a
|
||||
// RichChatView whose message list grows with content) can
|
||||
// bubble up through NavigationSplitView's detail slot and
|
||||
// push the whole window past the screen. Same pattern as
|
||||
// the Sessions tab fix in the v2.3 branch.
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
// v2.3: reflect the active Scarf project in the nav title
|
||||
// so the user can see at a glance that the chat is scoped
|
||||
// (complements the folder chip in SessionInfoBar). Falls
|
||||
// back to the plain "Chat" label for global chats.
|
||||
.navigationTitle(
|
||||
viewModel.currentProjectName.map { "Chat · \($0)" } ?? "Chat"
|
||||
)
|
||||
.task {
|
||||
await viewModel.loadRecentSessions()
|
||||
viewModel.refreshCredentialPreflight()
|
||||
// Cold-launch handoff: if the user clicked "New Chat" on
|
||||
// a project before ChatView had a chance to render, the
|
||||
// coordinator was already populated. Consume the request
|
||||
// here. The onChange below handles the live case.
|
||||
if let pending = coordinator.pendingProjectChat {
|
||||
coordinator.pendingProjectChat = nil
|
||||
viewModel.startNewSession(projectPath: pending)
|
||||
}
|
||||
// Same story for resume-session handoff: the user clicked
|
||||
// a session in the Projects Sessions tab (routes to `.chat`
|
||||
// rather than `.sessions` so the chat actually reopens).
|
||||
// SessionsView consumes `selectedSessionId` for its own
|
||||
// routing; Chat now consumes it too. Mutually exclusive at
|
||||
// any given render because only one section is active per
|
||||
// `coordinator.selectedSection`. `else if` makes precedence
|
||||
// explicit — pendingProjectChat (new) outranks
|
||||
// selectedSessionId (resume) when both are somehow set.
|
||||
else if let pendingId = coordinator.selectedSessionId {
|
||||
coordinator.selectedSessionId = nil
|
||||
viewModel.resumeSession(pendingId)
|
||||
}
|
||||
}
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
Task { await viewModel.loadRecentSessions() }
|
||||
viewModel.refreshCredentialPreflight()
|
||||
}
|
||||
// Live handoff from the per-project Sessions tab: the tab
|
||||
// sets `pendingProjectChat` + flips `selectedSection` to
|
||||
// `.chat`; this view consumes the path and starts a fresh
|
||||
// session with cwd=projectPath. Attribution happens inside
|
||||
// ChatViewModel on successful session creation.
|
||||
.onChange(of: coord.pendingProjectChat) { _, new in
|
||||
if let projectPath = new {
|
||||
coordinator.pendingProjectChat = nil
|
||||
viewModel.startNewSession(projectPath: projectPath)
|
||||
}
|
||||
}
|
||||
// Live handoff for resume: user clicked an existing session in
|
||||
// the Projects Sessions tab while already in the Chat section
|
||||
// (or switched back to Chat after). Project-chip rendering
|
||||
// happens automatically inside ChatViewModel.resumeSession ->
|
||||
// startACPSession via the attribution.projectPath(for:) lookup.
|
||||
.onChange(of: coord.selectedSessionId) { _, new in
|
||||
if let sessionId = new {
|
||||
coordinator.selectedSessionId = nil
|
||||
viewModel.resumeSession(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Banner rendered between the toolbar and the chat area when either
|
||||
@@ -361,7 +419,7 @@ struct ChatView: View {
|
||||
// MARK: - Permission Approval View
|
||||
|
||||
extension RichChatViewModel.PendingPermission: Identifiable {
|
||||
var id: Int { requestId }
|
||||
public var id: Int { requestId }
|
||||
}
|
||||
|
||||
struct PermissionApprovalView: View {
|
||||
|
||||
@@ -18,7 +18,13 @@ struct RichChatView: View {
|
||||
isWorking: richChat.isAgentWorking,
|
||||
acpInputTokens: richChat.acpInputTokens,
|
||||
acpOutputTokens: richChat.acpOutputTokens,
|
||||
acpThoughtTokens: richChat.acpThoughtTokens
|
||||
acpThoughtTokens: richChat.acpThoughtTokens,
|
||||
// v2.3: surface the active Scarf project (if any) as
|
||||
// a folder chip at the start of the bar. Driven by
|
||||
// ChatViewModel.currentProjectName which is set in
|
||||
// startACPSession on both new project chats and
|
||||
// resumed project-attributed sessions.
|
||||
projectName: chatViewModel.currentProjectName
|
||||
)
|
||||
Divider()
|
||||
|
||||
@@ -43,6 +49,19 @@ struct RichChatView: View {
|
||||
showCompressButton: richChat.supportsCompress && !richChat.hasBroaderCommandMenu
|
||||
)
|
||||
}
|
||||
// `idealHeight: 500` caps what this subtree REPORTS as its ideal
|
||||
// height. Load-bearing: RichChatMessageList uses a plain VStack
|
||||
// (not LazyVStack — see RichChatMessageList.swift:13-24 for the
|
||||
// rationale) inside a ScrollView, so its natural ideal grows
|
||||
// with message count. Under the WindowGroup's
|
||||
// `.windowResizability(.contentMinSize)` policy, that uncapped
|
||||
// ideal would open the window at a height that exceeds the
|
||||
// screen on long conversations, pushing the input bar below
|
||||
// the visible desktop. `maxHeight: .infinity` still lets the
|
||||
// view fill any larger offered space, and `minHeight: 0`
|
||||
// allows it to shrink freely — the ideal cap only affects the
|
||||
// initial-size hint reported up to the window.
|
||||
.frame(minHeight: 0, idealHeight: 500, maxHeight: .infinity)
|
||||
// DB polling fallback for terminal mode only — never overwrite ACP messages
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
if !isACPMode, !richChat.hasMessages, richChat.sessionId != nil {
|
||||
|
||||
@@ -8,10 +8,28 @@ struct SessionInfoBar: View {
|
||||
var acpInputTokens: Int = 0
|
||||
var acpOutputTokens: Int = 0
|
||||
var acpThoughtTokens: Int = 0
|
||||
/// Name of the Scarf project this session is attributed to, when
|
||||
/// applicable. Nil for plain global chats. Drives the folder-chip
|
||||
/// indicator rendered before the session title. Resolved by
|
||||
/// `ChatViewModel.currentProjectName` — the view just passes it
|
||||
/// through.
|
||||
var projectName: String? = nil
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 16) {
|
||||
if let session {
|
||||
// Project indicator first — visually anchors the session
|
||||
// as "scoped to project X" before the working dot and
|
||||
// title. Hidden for non-project chats so the bar looks
|
||||
// identical to v2.2.1 behavior.
|
||||
if let projectName {
|
||||
Label(projectName, systemImage: "folder.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.tint)
|
||||
.lineLimit(1)
|
||||
.help("Chat is scoped to Scarf project \"\(projectName)\"")
|
||||
}
|
||||
|
||||
HStack(spacing: 4) {
|
||||
Circle()
|
||||
.fill(isWorking ? .green : .secondary)
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
|
||||
/// Describes whether Credential Pools' generic OAuth flow
|
||||
/// (``OAuthFlowController``) can handle a given provider.
|
||||
///
|
||||
/// Hermes supports four OAuth styles, and only **PKCE** is driven by the
|
||||
/// generic controller:
|
||||
///
|
||||
/// | Style | Works via `OAuthFlowController`? | Example providers |
|
||||
/// |---|---|---|
|
||||
/// | PKCE | ✅ Yes | anthropic, github-copilot |
|
||||
/// | Device-code | ❌ No — stalls silently | nous |
|
||||
/// | External OAuth | ❌ No — needs a terminal | openai-codex, qwen-oauth, google-gemini-cli |
|
||||
/// | External process | ❌ No — uses an agent bridge | copilot-acp |
|
||||
///
|
||||
/// Routing a non-PKCE provider through the generic controller silently
|
||||
/// fails: the PKCE URL regex in ``OAuthFlowController/extractAuthURL`` only
|
||||
/// matches `client_id=…&redirect_uri=…` -shaped strings, and nothing else
|
||||
/// hermes prints for the other styles matches that. This gate closes the
|
||||
/// dead end by steering the user to the right flow for each style.
|
||||
///
|
||||
/// `.ok` is the default for unknown providers so existing PKCE-based
|
||||
/// flows (anthropic, etc.) keep working — this gate is strictly additive.
|
||||
enum CredentialPoolsOAuthGate: Equatable {
|
||||
/// The standard PKCE flow works for this provider — show the normal
|
||||
/// "Start OAuth" button and let ``OAuthFlowController`` handle it.
|
||||
case ok
|
||||
/// User hasn't typed a provider ID yet. Disable the button.
|
||||
case providerEmpty
|
||||
/// Route Nous Portal through ``NousSignInSheet`` instead of the
|
||||
/// generic flow, since Nous uses device-code.
|
||||
case useNousSignIn
|
||||
/// Hermes knows how to sign in to this provider but Scarf doesn't yet
|
||||
/// have a dedicated UI for it. Point the user to `hermes auth add
|
||||
/// <provider>` in a terminal.
|
||||
case useCLI(provider: String)
|
||||
|
||||
/// Compute the gate for a typed provider ID. Consults the Hermes
|
||||
/// overlay table via ``ModelCatalogService/overlayMetadata(for:)`` to
|
||||
/// decide which OAuth style applies.
|
||||
static func resolve(providerID rawID: String, catalog: ModelCatalogService) -> CredentialPoolsOAuthGate {
|
||||
let id = rawID.trimmingCharacters(in: .whitespaces).lowercased()
|
||||
guard !id.isEmpty else { return .providerEmpty }
|
||||
if id == "nous" { return .useNousSignIn }
|
||||
switch catalog.overlayMetadata(for: id)?.authType {
|
||||
case .oauthDeviceCode, .oauthExternal, .externalProcess:
|
||||
return .useCLI(provider: id)
|
||||
default:
|
||||
return .ok
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,33 @@ struct HermesCredential: Identifiable, Sendable, Equatable {
|
||||
let tokenTail: String // Last 4 chars of the token — NEVER store full token in UI state
|
||||
let lastStatus: String // "ok" | "cooldown" | "exhausted" | ""
|
||||
let requestCount: Int
|
||||
/// OAuth access-token expiry. Populated from `expires_at_ms` (epoch ms,
|
||||
/// preferred) or `expires_at` (ISO8601). Nil for API-key entries and
|
||||
/// for OAuth providers that haven't yet recorded an expiry.
|
||||
let expiresAt: Date?
|
||||
/// When the current Nous agent key was minted — surfaced so users can
|
||||
/// tell whether a recent rotation has gone through. Nil for non-Nous
|
||||
/// providers and for older Nous entries without the field.
|
||||
let agentKeyObtainedAt: Date?
|
||||
|
||||
/// Display-time badge for expiry. Recomputed against `Date()` on each
|
||||
/// render so the label stays current without needing a timer.
|
||||
enum ExpiryBadge: Equatable {
|
||||
case expired
|
||||
case expiringSoon(days: Int)
|
||||
}
|
||||
|
||||
/// Returns a badge when expiry is within 7 days or already past. Nil
|
||||
/// means "not worth flagging" — either expiry is unknown or still far
|
||||
/// enough out that a warning would be noise.
|
||||
func expiryBadge(now: Date = Date()) -> ExpiryBadge? {
|
||||
guard let expiresAt else { return nil }
|
||||
if expiresAt <= now { return .expired }
|
||||
let seconds = expiresAt.timeIntervalSince(now)
|
||||
let days = Int(seconds / 86_400)
|
||||
if days <= 7 { return .expiringSoon(days: max(1, days)) }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Summary of one provider's pool with its rotation strategy.
|
||||
@@ -102,7 +129,9 @@ final class CredentialPoolsViewModel {
|
||||
source: entry.source ?? "",
|
||||
tokenTail: Self.tail(of: entry.access_token ?? ""),
|
||||
lastStatus: entry.last_status ?? "",
|
||||
requestCount: entry.request_count ?? 0
|
||||
requestCount: entry.request_count ?? 0,
|
||||
expiresAt: Self.resolveExpiry(msField: entry.expires_at_ms, isoField: entry.expires_at),
|
||||
agentKeyObtainedAt: Self.parseISO8601(entry.agent_key_obtained_at)
|
||||
)
|
||||
}
|
||||
return HermesCredentialPool(
|
||||
@@ -113,6 +142,30 @@ final class CredentialPoolsViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Prefer `expires_at_ms` (integer epoch ms — unambiguous) over
|
||||
/// `expires_at` (ISO8601 string). Hermes writes whichever format the
|
||||
/// upstream provider returned; new entries almost always carry the ms
|
||||
/// form, older Nous entries may only have the ISO form.
|
||||
nonisolated private static func resolveExpiry(msField: Double?, isoField: String?) -> Date? {
|
||||
if let ms = msField, ms > 0 {
|
||||
return Date(timeIntervalSince1970: ms / 1000.0)
|
||||
}
|
||||
return parseISO8601(isoField)
|
||||
}
|
||||
|
||||
nonisolated private static func parseISO8601(_ str: String?) -> Date? {
|
||||
guard let s = str, !s.isEmpty else { return nil }
|
||||
// Fractional seconds are present on Nous tokens; plain seconds on
|
||||
// most OAuth providers. Try the fractional parser first, fall back
|
||||
// to the strict one.
|
||||
let withFractional = ISO8601DateFormatter()
|
||||
withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = withFractional.date(from: s) { return d }
|
||||
let plain = ISO8601DateFormatter()
|
||||
plain.formatOptions = [.withInternetDateTime]
|
||||
return plain.date(from: s)
|
||||
}
|
||||
|
||||
/// Return last 4 chars prefixed with "…", or "" if the token is too short.
|
||||
/// Callers MUST NOT pass the full token anywhere user-visible beyond this.
|
||||
nonisolated private static func tail(of token: String) -> String {
|
||||
@@ -251,9 +304,20 @@ private struct AuthEntry: Decodable, Sendable {
|
||||
nonisolated let access_token: String?
|
||||
nonisolated let last_status: String?
|
||||
nonisolated let request_count: Int?
|
||||
/// Epoch milliseconds. Double (not Int64) because some Nous entries
|
||||
/// round-trip through JS and end up as `1780339200000.0`. Decoding as
|
||||
/// Int would throw on the fractional zero.
|
||||
nonisolated let expires_at_ms: Double?
|
||||
/// ISO8601 — fallback when `expires_at_ms` isn't present.
|
||||
nonisolated let expires_at: String?
|
||||
/// Nous-specific — when the current agent key was issued. Surfaced as
|
||||
/// "Agent key rotated Nh ago" so the user can tell if a recent manual
|
||||
/// rotation has taken effect.
|
||||
nonisolated let agent_key_obtained_at: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case id, label, auth_type, source, access_token, last_status, request_count
|
||||
case expires_at_ms, expires_at, agent_key_obtained_at
|
||||
}
|
||||
|
||||
nonisolated init(from decoder: any Decoder) throws {
|
||||
@@ -265,5 +329,8 @@ private struct AuthEntry: Decodable, Sendable {
|
||||
self.access_token = try c.decodeIfPresent(String.self, forKey: .access_token)
|
||||
self.last_status = try c.decodeIfPresent(String.self, forKey: .last_status)
|
||||
self.request_count = try c.decodeIfPresent(Int.self, forKey: .request_count)
|
||||
self.expires_at_ms = try c.decodeIfPresent(Double.self, forKey: .expires_at_ms)
|
||||
self.expires_at = try c.decodeIfPresent(String.self, forKey: .expires_at)
|
||||
self.agent_key_obtained_at = try c.decodeIfPresent(String.self, forKey: .agent_key_obtained_at)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ struct CredentialPoolsView: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(statusColor(cred.lastStatus))
|
||||
}
|
||||
expiryBadge(cred)
|
||||
}
|
||||
HStack(spacing: 8) {
|
||||
Text(cred.tokenTail.isEmpty ? "—" : cred.tokenTail)
|
||||
@@ -151,6 +152,11 @@ struct CredentialPoolsView: View {
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
if let rotated = cred.agentKeyObtainedAt {
|
||||
Text("agent key · \(Self.relativeAge(rotated))")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer()
|
||||
@@ -180,6 +186,45 @@ struct CredentialPoolsView: View {
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
/// Red "expired" / orange "expires in Nd" pill shown inline with the
|
||||
/// credential's auth-type chip. Hidden when the credential has no
|
||||
/// expiry or is more than 7 days out — no point pulling attention to a
|
||||
/// token the user doesn't need to think about yet.
|
||||
@ViewBuilder
|
||||
private func expiryBadge(_ cred: HermesCredential) -> some View {
|
||||
if let badge = cred.expiryBadge() {
|
||||
switch badge {
|
||||
case .expired:
|
||||
Text("expired")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.red)
|
||||
.clipShape(Capsule())
|
||||
case .expiringSoon(let days):
|
||||
Text("expires in \(days)d")
|
||||
.font(.caption2.weight(.semibold))
|
||||
.foregroundStyle(.white)
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.orange)
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// "2h ago" / "3d ago" / "just now". Kept terse for the one-line
|
||||
/// credential row. `RelativeDateTimeFormatter` isn't used because its
|
||||
/// output ("2 hours ago") is too long for the slot.
|
||||
private static func relativeAge(_ date: Date, now: Date = Date()) -> String {
|
||||
let seconds = Int(now.timeIntervalSince(date))
|
||||
if seconds < 60 { return "just now" }
|
||||
if seconds < 3600 { return "\(seconds / 60)m ago" }
|
||||
if seconds < 86_400 { return "\(seconds / 3600)h ago" }
|
||||
return "\(seconds / 86_400)d ago"
|
||||
}
|
||||
}
|
||||
|
||||
/// Two-step sheet for adding a credential:
|
||||
@@ -211,9 +256,18 @@ private struct AddCredentialSheet: View {
|
||||
@State private var providers: [HermesProviderInfo] = []
|
||||
@State private var oauthStarted: Bool = false
|
||||
@State private var authCode: String = ""
|
||||
/// Drives presentation of the dedicated Nous sign-in sheet from inside
|
||||
/// this add-credential sheet. Nous uses device-code, not PKCE — the
|
||||
/// regular `OAuthFlowController` silently stalls, so we route Nous
|
||||
/// through ``NousSignInSheet`` instead.
|
||||
@State private var showNousSignIn: Bool = false
|
||||
|
||||
private var catalog: ModelCatalogService { ModelCatalogService(context: viewModel.context) }
|
||||
|
||||
private func oauthGate(for rawID: String) -> CredentialPoolsOAuthGate {
|
||||
CredentialPoolsOAuthGate.resolve(providerID: rawID, catalog: catalog)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
Text("Add Credential")
|
||||
@@ -241,6 +295,17 @@ private struct AddCredentialSheet: View {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
// Nous sign-in is a parallel flow that bypasses OAuthFlowController.
|
||||
// When it completes, the parent list refreshes from auth.json just
|
||||
// like it does after a regular OAuth add — so we dismiss the
|
||||
// AddCredentialSheet after a short delay.
|
||||
.sheet(isPresented: $showNousSignIn) {
|
||||
NousSignInSheet {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.8) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Step 1: provider + type + label + optional API key
|
||||
@@ -291,11 +356,57 @@ private struct AddCredentialSheet: View {
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
}
|
||||
} else {
|
||||
oauthPreamble
|
||||
oauthGuidance
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Renders either the standard PKCE preamble, the Nous-specific
|
||||
/// "sign in with the dedicated sheet" affordance, or a CLI fallback —
|
||||
/// whichever matches the provider the user has typed.
|
||||
@ViewBuilder
|
||||
private var oauthGuidance: some View {
|
||||
switch oauthGate(for: providerID) {
|
||||
case .ok, .providerEmpty:
|
||||
oauthPreamble
|
||||
case .useNousSignIn:
|
||||
nousSignInPreamble
|
||||
case .useCLI(let provider):
|
||||
cliFallbackPreamble(for: provider)
|
||||
}
|
||||
}
|
||||
|
||||
private var nousSignInPreamble: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "sparkles")
|
||||
.foregroundStyle(.tint)
|
||||
Text("Nous Portal uses a dedicated sign-in flow.")
|
||||
.font(.caption)
|
||||
}
|
||||
Text("We'll open the Nous Portal approval page in your browser and show the device code here. No code-paste step.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func cliFallbackPreamble(for provider: String) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "terminal")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("`\(provider)` uses a different sign-in flow.")
|
||||
.font(.caption)
|
||||
}
|
||||
Text("Run `hermes auth add \(provider)` in a terminal to finish sign-in. In-app support for this provider is coming in a follow-up.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
}
|
||||
|
||||
/// Brief explanation shown before the user clicks "Start OAuth". Sets
|
||||
/// expectations about the embedded-terminal flow so the browser window
|
||||
/// and code-paste step aren't surprises.
|
||||
@@ -477,14 +588,38 @@ private struct AddCredentialSheet: View {
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty || apiKey.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
} else {
|
||||
Button("Start OAuth") {
|
||||
viewModel.startOAuth(provider: providerID, label: label)
|
||||
oauthStarted = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(providerID.trimmingCharacters(in: .whitespaces).isEmpty)
|
||||
oauthActionButton
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Gate-aware OAuth primary action. For PKCE providers it's the
|
||||
/// unchanged "Start OAuth" button; for Nous it's "Sign in to Nous
|
||||
/// Portal" (opens ``NousSignInSheet``); for other device-code /
|
||||
/// external providers it's a disabled button with a CLI hint inline.
|
||||
@ViewBuilder
|
||||
private var oauthActionButton: some View {
|
||||
switch oauthGate(for: providerID) {
|
||||
case .providerEmpty:
|
||||
Button("Start OAuth") {}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(true)
|
||||
case .ok:
|
||||
Button("Start OAuth") {
|
||||
viewModel.startOAuth(provider: providerID, label: label)
|
||||
oauthStarted = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .useNousSignIn:
|
||||
Button("Sign in to Nous Portal") {
|
||||
showNousSignIn = true
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .useCLI:
|
||||
Button("Start OAuth") {}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,7 +96,7 @@ struct DashboardView: View {
|
||||
color: .purple
|
||||
)
|
||||
StatusCard(
|
||||
title: "Gateway",
|
||||
title: "Messaging Gateway",
|
||||
value: viewModel.gatewayState?.statusText ?? "unknown",
|
||||
icon: "network",
|
||||
color: viewModel.gatewayState?.isRunning == true ? .green : .secondary
|
||||
|
||||
@@ -20,7 +20,7 @@ struct GatewayView: View {
|
||||
.padding()
|
||||
.frame(maxWidth: .infinity, alignment: .topLeading)
|
||||
}
|
||||
.navigationTitle("Gateway")
|
||||
.navigationTitle("Messaging Gateway")
|
||||
.onAppear { viewModel.load() }
|
||||
.onChange(of: fileWatcher.lastChangeDate) { viewModel.load() }
|
||||
}
|
||||
|
||||
@@ -1,5 +1,23 @@
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
#if canImport(AppKit)
|
||||
import AppKit
|
||||
#endif
|
||||
import os
|
||||
|
||||
/// Observed state of the local `hermes dashboard` web UI (introduced in
|
||||
/// Hermes v0.10.x). `port` defaults to 9119 — the CLI's default and the only
|
||||
/// value Scarf launches with today.
|
||||
struct WebDashboardStatus: Sendable, Equatable {
|
||||
var running: Bool
|
||||
var port: Int
|
||||
/// True while a start/stop transition is in flight so the UI can disable
|
||||
/// buttons and show a spinner.
|
||||
var busy: Bool
|
||||
|
||||
static let defaultPort = 9119
|
||||
static let unknown = WebDashboardStatus(running: false, port: defaultPort, busy: false)
|
||||
}
|
||||
|
||||
struct HealthCheck: Identifiable {
|
||||
let id = UUID()
|
||||
@@ -25,10 +43,12 @@ struct HealthSection: Identifiable {
|
||||
final class HealthViewModel {
|
||||
let context: ServerContext
|
||||
private let fileService: HermesFileService
|
||||
private let subscriptionService: NousSubscriptionService
|
||||
|
||||
init(context: ServerContext = .local) {
|
||||
self.context = context
|
||||
self.fileService = HermesFileService(context: context)
|
||||
self.subscriptionService = NousSubscriptionService(context: context)
|
||||
}
|
||||
|
||||
|
||||
@@ -49,10 +69,24 @@ final class HealthViewModel {
|
||||
var diagnosticsOutput: String = ""
|
||||
var isSharingDebug = false
|
||||
|
||||
/// Liveness + control state for `hermes dashboard` (local web UI). The
|
||||
/// section in `HealthView` is hidden for remote contexts — the dashboard
|
||||
/// binds 127.0.0.1 by default and remote probing / tunneling is out of
|
||||
/// scope for v1.
|
||||
var dashboardStatus: WebDashboardStatus = .unknown
|
||||
/// Our own spawned subprocess, if the user hit "Launch Dashboard" from
|
||||
/// Scarf. Nil when the dashboard was started externally (we still detect
|
||||
/// it via the probe but can't terminate it cleanly via `Process.terminate`).
|
||||
private var dashboardProcess: Process?
|
||||
/// Background polling loop; started in `startDashboardMonitoring()` and
|
||||
/// cancelled on view disappear.
|
||||
private var dashboardProbeTask: Task<Void, Never>?
|
||||
|
||||
func load() {
|
||||
isLoading = true
|
||||
let ctx = context
|
||||
let svc = fileService
|
||||
let subSvc = subscriptionService
|
||||
// Health runs four sync transport-mediated commands plus a process
|
||||
// probe — that's 4-5 ssh round-trips on remote, easily 1-2s. Detach
|
||||
// the whole load.
|
||||
@@ -61,6 +95,8 @@ final class HealthViewModel {
|
||||
let versionOutput = ctx.runHermes(["version"]).output
|
||||
let statusOutput = ctx.runHermes(["status"]).output
|
||||
let doctorOutput = ctx.runHermes(["doctor"]).output
|
||||
let subscription = subSvc.loadState()
|
||||
let config = svc.loadConfig()
|
||||
|
||||
let lines = versionOutput.components(separatedBy: "\n")
|
||||
let version = lines.first ?? ""
|
||||
@@ -69,6 +105,7 @@ final class HealthViewModel {
|
||||
let updateInfo = updateLine?.trimmingCharacters(in: .whitespaces) ?? ""
|
||||
|
||||
let statusSections = Self.parseOutputStatic(statusOutput)
|
||||
+ [Self.toolGatewaySection(subscription: subscription, config: config)]
|
||||
let doctorSections = Self.parseOutputStatic(doctorOutput)
|
||||
|
||||
await MainActor.run { [weak self] in
|
||||
@@ -86,6 +123,80 @@ final class HealthViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
/// Synthesize a Tool Gateway health section from the subscription state +
|
||||
/// `platform_toolsets` table. Runs alongside the other status sections so
|
||||
/// the user sees at a glance whether their Nous Portal subscription is
|
||||
/// wired up.
|
||||
///
|
||||
/// This is distinct from the "Messaging Gateway" (inbound Slack/Discord/…
|
||||
/// requests) — the two are unrelated systems that unfortunately share the
|
||||
/// "gateway" name in Hermes's CLI output.
|
||||
///
|
||||
/// `nonisolated` so `load()` can call it from `Task.detached` alongside
|
||||
/// `parseOutputStatic` without hopping back to MainActor.
|
||||
nonisolated private static func toolGatewaySection(subscription: NousSubscriptionState, config: HermesConfig) -> HealthSection {
|
||||
var checks: [HealthCheck] = []
|
||||
|
||||
let subscriptionCheck: HealthCheck = {
|
||||
if subscription.subscribed {
|
||||
return HealthCheck(
|
||||
label: "Nous Portal subscription active",
|
||||
status: .ok,
|
||||
detail: "Tool requests route through the Nous Portal gateway."
|
||||
)
|
||||
}
|
||||
if subscription.present {
|
||||
return HealthCheck(
|
||||
label: "Signed in, but Nous isn't the active provider",
|
||||
status: .warning,
|
||||
detail: "Open Settings → General and pick Nous Portal to route tools through the gateway."
|
||||
)
|
||||
}
|
||||
return HealthCheck(
|
||||
label: "Not subscribed",
|
||||
status: .warning,
|
||||
detail: "Run `hermes auth` and pick Nous Portal to enable subscription-gated tools."
|
||||
)
|
||||
}()
|
||||
checks.append(subscriptionCheck)
|
||||
|
||||
if !config.platformToolsets.isEmpty {
|
||||
let platforms = config.platformToolsets.keys.sorted()
|
||||
for platform in platforms {
|
||||
let toolsets = config.platformToolsets[platform] ?? []
|
||||
checks.append(HealthCheck(
|
||||
label: "\(platform): \(toolsets.count) toolset\(toolsets.count == 1 ? "" : "s")",
|
||||
status: .ok,
|
||||
detail: toolsets.joined(separator: ", ")
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
let auxOnNous = [
|
||||
("vision", config.auxiliary.vision.provider),
|
||||
("web_extract", config.auxiliary.webExtract.provider),
|
||||
("compression", config.auxiliary.compression.provider),
|
||||
("session_search", config.auxiliary.sessionSearch.provider),
|
||||
("skills_hub", config.auxiliary.skillsHub.provider),
|
||||
("approval", config.auxiliary.approval.provider),
|
||||
("mcp", config.auxiliary.mcp.provider),
|
||||
("flush_memories", config.auxiliary.flushMemories.provider),
|
||||
].filter { $0.1 == "nous" }.map(\.0)
|
||||
if !auxOnNous.isEmpty {
|
||||
checks.append(HealthCheck(
|
||||
label: "Auxiliary tasks routed through Nous",
|
||||
status: subscription.subscribed ? .ok : .warning,
|
||||
detail: auxOnNous.joined(separator: ", ")
|
||||
))
|
||||
}
|
||||
|
||||
return HealthSection(
|
||||
title: "Tool Gateway",
|
||||
icon: "arrow.triangle.branch",
|
||||
checks: checks
|
||||
)
|
||||
}
|
||||
|
||||
func refreshProcessStatus() {
|
||||
let svc = fileService
|
||||
Task.detached { [weak self] in
|
||||
@@ -368,4 +479,191 @@ final class HealthViewModel {
|
||||
private func runHermes(_ arguments: [String]) -> (output: String, exitCode: Int32) {
|
||||
context.runHermes(arguments)
|
||||
}
|
||||
|
||||
// MARK: - Web Dashboard (`hermes dashboard`)
|
||||
|
||||
/// Called from `HealthView.onAppear`. Starts a background loop that
|
||||
/// probes `http://127.0.0.1:<port>/api/status` every 3s and keeps
|
||||
/// `dashboardStatus.running` in sync with reality — whether we launched
|
||||
/// the dashboard or the user did via terminal. No-op on remote contexts.
|
||||
func startDashboardMonitoring() {
|
||||
guard !context.isRemote else { return }
|
||||
dashboardProbeTask?.cancel()
|
||||
let port = dashboardStatus.port
|
||||
dashboardProbeTask = Task { [weak self] in
|
||||
while !Task.isCancelled {
|
||||
let running = await Self.probeDashboard(port: port)
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
// Preserve `busy` so the button stays disabled during an
|
||||
// in-flight start/stop; only toggle the `running` bit.
|
||||
self.dashboardStatus = WebDashboardStatus(
|
||||
running: running,
|
||||
port: self.dashboardStatus.port,
|
||||
busy: self.dashboardStatus.busy
|
||||
)
|
||||
// Reap our spawned process if it exited externally.
|
||||
if !running, let p = self.dashboardProcess, !p.isRunning {
|
||||
self.dashboardProcess = nil
|
||||
}
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 3_000_000_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func stopDashboardMonitoring() {
|
||||
dashboardProbeTask?.cancel()
|
||||
dashboardProbeTask = nil
|
||||
}
|
||||
|
||||
/// Launch `hermes dashboard --no-open --port 9119` detached. We pass
|
||||
/// `--no-open` so Hermes doesn't try to open its own browser tab — Scarf
|
||||
/// opens the URL after the probe confirms the server is listening, which
|
||||
/// avoids the "Safari tab loads faster than uvicorn binds the port" race.
|
||||
func launchDashboard() {
|
||||
guard !context.isRemote else { return }
|
||||
guard !dashboardStatus.running, !dashboardStatus.busy else { return }
|
||||
guard let binary = fileService.hermesBinaryPath() else {
|
||||
actionMessage = "hermes binary not found"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
dashboardStatus = WebDashboardStatus(
|
||||
running: dashboardStatus.running,
|
||||
port: dashboardStatus.port,
|
||||
busy: true
|
||||
)
|
||||
actionMessage = "Starting dashboard…"
|
||||
|
||||
let port = dashboardStatus.port
|
||||
let proc = Process()
|
||||
proc.executableURL = URL(fileURLWithPath: binary)
|
||||
proc.arguments = ["dashboard", "--no-open", "--port", String(port)]
|
||||
proc.environment = HermesFileService.enrichedEnvironment()
|
||||
// Discard stdout/stderr — we rely on the HTTP probe for liveness and
|
||||
// don't want a growing pipe buffer to block the subprocess.
|
||||
proc.standardOutput = FileHandle.nullDevice
|
||||
proc.standardError = FileHandle.nullDevice
|
||||
|
||||
do {
|
||||
try proc.run()
|
||||
dashboardProcess = proc
|
||||
Task { [weak self] in
|
||||
// Give uvicorn up to ~6 seconds to bind the port, probing
|
||||
// every 300ms. First 200 response opens the browser.
|
||||
for _ in 0..<20 {
|
||||
if await Self.probeDashboard(port: port) {
|
||||
if let url = URL(string: "http://127.0.0.1:\(port)") {
|
||||
await MainActor.run {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 300_000_000)
|
||||
}
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self else { return }
|
||||
self.dashboardStatus = WebDashboardStatus(
|
||||
running: self.dashboardStatus.running,
|
||||
port: self.dashboardStatus.port,
|
||||
busy: false
|
||||
)
|
||||
self.actionMessage = nil
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Self.dashboardLogger.error("Failed to spawn hermes dashboard: \(error.localizedDescription, privacy: .public)")
|
||||
dashboardProcess = nil
|
||||
dashboardStatus = WebDashboardStatus(
|
||||
running: dashboardStatus.running,
|
||||
port: dashboardStatus.port,
|
||||
busy: false
|
||||
)
|
||||
actionMessage = "Failed to start: \(error.localizedDescription)"
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 3) { [weak self] in
|
||||
self?.actionMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop the dashboard. If Scarf spawned it, send SIGTERM directly. If an
|
||||
/// external instance is running, fall back to `pkill -f "hermes dashboard"`
|
||||
/// so the Stop button works regardless of who launched it.
|
||||
func stopDashboard() {
|
||||
guard !context.isRemote else { return }
|
||||
dashboardStatus = WebDashboardStatus(
|
||||
running: dashboardStatus.running,
|
||||
port: dashboardStatus.port,
|
||||
busy: true
|
||||
)
|
||||
actionMessage = "Stopping dashboard…"
|
||||
|
||||
if let proc = dashboardProcess, proc.isRunning {
|
||||
proc.terminate()
|
||||
dashboardProcess = nil
|
||||
} else {
|
||||
// External instance — best-effort pkill.
|
||||
let kill = Process()
|
||||
kill.executableURL = URL(fileURLWithPath: "/usr/bin/pkill")
|
||||
kill.arguments = ["-f", "hermes dashboard"]
|
||||
_ = try? kill.run()
|
||||
kill.waitUntilExit()
|
||||
}
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
|
||||
guard let self else { return }
|
||||
Task {
|
||||
let running = await Self.probeDashboard(port: self.dashboardStatus.port)
|
||||
await MainActor.run {
|
||||
self.dashboardStatus = WebDashboardStatus(
|
||||
running: running,
|
||||
port: self.dashboardStatus.port,
|
||||
busy: false
|
||||
)
|
||||
self.actionMessage = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Open the dashboard in the default browser. Safe to call only when the
|
||||
/// probe reports `running: true` — UI gates the button on that.
|
||||
func openDashboardInBrowser() {
|
||||
guard let url = URL(string: "http://127.0.0.1:\(dashboardStatus.port)") else { return }
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
|
||||
/// HEAD-shaped GET against `/api/status`. Returns true on any 2xx response.
|
||||
/// `/api/status` is whitelisted in `_PUBLIC_API_PATHS` in Hermes's
|
||||
/// `web_server.py` — no token required, so a bare GET works.
|
||||
///
|
||||
/// `nonisolated` + `async` so the polling loop can call it without
|
||||
/// bouncing through MainActor on every tick.
|
||||
nonisolated private static func probeDashboard(port: Int) async -> Bool {
|
||||
guard let url = URL(string: "http://127.0.0.1:\(port)/api/status") else { return false }
|
||||
var request = URLRequest(url: url)
|
||||
request.timeoutInterval = 0.5
|
||||
request.httpMethod = "GET"
|
||||
let config = URLSessionConfiguration.ephemeral
|
||||
config.timeoutIntervalForRequest = 0.5
|
||||
config.timeoutIntervalForResource = 1.0
|
||||
let session = URLSession(configuration: config)
|
||||
defer { session.invalidateAndCancel() }
|
||||
do {
|
||||
let (_, response) = try await session.data(for: request)
|
||||
if let http = response as? HTTPURLResponse {
|
||||
return (200..<300).contains(http.statusCode)
|
||||
}
|
||||
return false
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static let dashboardLogger = Logger(subsystem: "com.scarf", category: "WebDashboard")
|
||||
}
|
||||
|
||||
@@ -54,7 +54,11 @@ struct HealthView: View {
|
||||
label: "Running health checks…",
|
||||
isEmpty: viewModel.statusSections.isEmpty && viewModel.doctorSections.isEmpty
|
||||
)
|
||||
.onAppear { viewModel.load() }
|
||||
.onAppear {
|
||||
viewModel.load()
|
||||
viewModel.startDashboardMonitoring()
|
||||
}
|
||||
.onDisappear { viewModel.stopDashboardMonitoring() }
|
||||
.confirmationDialog("Upload debug report?", isPresented: $showShareConfirm) {
|
||||
Button("Upload", role: .destructive) {
|
||||
viewModel.runDebugShare()
|
||||
@@ -162,9 +166,56 @@ struct HealthView: View {
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
|
||||
if !viewModel.context.isRemote {
|
||||
Divider()
|
||||
webDashboardRow
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Status + controls for `hermes dashboard` (the web UI introduced in
|
||||
/// v0.10.x). Hidden for remote contexts — the dashboard binds 127.0.0.1
|
||||
/// and remote tunneling is deferred.
|
||||
private var webDashboardRow: some View {
|
||||
HStack(spacing: 16) {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: "safari")
|
||||
.foregroundStyle(viewModel.dashboardStatus.running ? .green : .secondary)
|
||||
.font(.caption)
|
||||
if viewModel.dashboardStatus.running {
|
||||
Text("Web Dashboard on :\(viewModel.dashboardStatus.port)")
|
||||
.font(.caption.bold())
|
||||
} else {
|
||||
Text("Web Dashboard")
|
||||
.font(.caption.bold())
|
||||
Text("not running")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 8) {
|
||||
if viewModel.dashboardStatus.running {
|
||||
Button("Open in Browser") { viewModel.openDashboardInBrowser() }
|
||||
Button("Stop") { viewModel.stopDashboard() }
|
||||
.disabled(viewModel.dashboardStatus.busy)
|
||||
} else {
|
||||
Button("Launch Dashboard") { viewModel.launchDashboard() }
|
||||
.disabled(viewModel.dashboardStatus.busy)
|
||||
}
|
||||
if viewModel.dashboardStatus.busy {
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal)
|
||||
.padding(.vertical, 8)
|
||||
}
|
||||
|
||||
// MARK: - Grid
|
||||
|
||||
private func sectionGrid(_ sections: [HealthSection]) -> some View {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct MCPServerEditorView: View {
|
||||
@State var viewModel: MCPServerEditorViewModel
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import Foundation
|
||||
import os
|
||||
import ScarfCore
|
||||
|
||||
/// Drives the per-project Sessions tab introduced in v2.3. Pulls the
|
||||
/// global session list from `HermesDataService`, filters by the
|
||||
/// attribution sidecar, and exposes a minimal surface for the view:
|
||||
/// the filtered sessions array, loading state, and a refresh entry
|
||||
/// point that the view can call on appearance + on file-watcher
|
||||
/// change.
|
||||
@Observable
|
||||
@MainActor
|
||||
final class ProjectSessionsViewModel {
|
||||
private static let logger = Logger(subsystem: "com.scarf", category: "ProjectSessionsViewModel")
|
||||
|
||||
private let dataService: HermesDataService
|
||||
private let attribution: SessionAttributionService
|
||||
private let project: ProjectEntry
|
||||
|
||||
init(context: ServerContext, project: ProjectEntry) {
|
||||
self.dataService = HermesDataService(context: context)
|
||||
self.attribution = SessionAttributionService(context: context)
|
||||
self.project = project
|
||||
}
|
||||
|
||||
/// Sessions attributed to the owning project, in the order
|
||||
/// `HermesDataService.fetchSessions` returns them (newest first).
|
||||
var sessions: [HermesSession] = []
|
||||
|
||||
/// True from `load()` start to its completion. The view renders
|
||||
/// a ProgressView during the first fetch; afterwards, re-fetches
|
||||
/// triggered by file-watcher changes happen silently.
|
||||
var isLoading: Bool = false
|
||||
|
||||
/// Short diagnostic string for an empty list — nil when sessions
|
||||
/// are loaded and populated, otherwise explains the empty state
|
||||
/// (no sessions ever created in this project, vs. no sessions
|
||||
/// matched the project's attribution map).
|
||||
var emptyStateHint: String?
|
||||
|
||||
/// Refresh the session list. Safe to call repeatedly; the data
|
||||
/// service reconnects to state.db on demand and the attribution
|
||||
/// service reads the sidecar afresh each call.
|
||||
func load() async {
|
||||
isLoading = true
|
||||
defer { isLoading = false }
|
||||
|
||||
let attributed = attribution.sessionIDs(forProject: project.path)
|
||||
if attributed.isEmpty {
|
||||
sessions = []
|
||||
emptyStateHint = "No chats have been started in this project yet. Click New Chat to begin."
|
||||
return
|
||||
}
|
||||
|
||||
// Open (or re-open for remote) the DB handle before querying.
|
||||
// `HermesDataService` is an actor with a lazily-initialised
|
||||
// SQLite pointer; every query method short-circuits to `[]`
|
||||
// when `db == nil`. This VM constructs its own service
|
||||
// instance (separate from ChatViewModel / InsightsVM /
|
||||
// ActivityVM), so we have to open it ourselves. Same
|
||||
// pattern used by those other VMs (`refresh()` rather than
|
||||
// `open()` because refresh also re-pulls the remote-server
|
||||
// snapshot on each call — local is a cheap no-op).
|
||||
_ = await dataService.refresh()
|
||||
|
||||
// Fetch a generous page; we filter client-side by attribution
|
||||
// map membership. The 200 ceiling matches other feature VMs
|
||||
// (ActivityViewModel, InsightsViewModel). HermesDataService
|
||||
// is an actor so this crosses the isolation boundary — the
|
||||
// SQLite read happens off the MainActor. If a single project
|
||||
// accumulates more than 200 attributed sessions, we'll need
|
||||
// a paged query; roadmap item, not a v2.3 problem.
|
||||
let all = await dataService.fetchSessions(limit: 200)
|
||||
let filtered = all.filter { attributed.contains($0.id) }
|
||||
sessions = filtered
|
||||
|
||||
if filtered.isEmpty {
|
||||
// Attribution map has entries but none appear in the
|
||||
// recent session fetch — likely stale sidecar entries
|
||||
// for sessions Hermes has since deleted. The view shows
|
||||
// an informational empty state; pruning stale entries
|
||||
// is a roadmap follow-up, not a blocker.
|
||||
emptyStateHint = "This project has \(attributed.count) attributed session\(attributed.count == 1 ? "" : "s"), but none are in the recent history. They may have been deleted from Hermes."
|
||||
} else {
|
||||
emptyStateHint = nil
|
||||
}
|
||||
}
|
||||
|
||||
/// Release the underlying DB handle. Safe to call repeatedly; the
|
||||
/// service re-opens on the next `load()`. Mirrors the pattern in
|
||||
/// ActivityViewModel.swift:80 — view calls this on `.onDisappear`
|
||||
/// so file descriptors and the SQLite cache don't dangle once
|
||||
/// the tab isn't visible.
|
||||
func close() async {
|
||||
await dataService.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Sheet for assigning a project to a folder in the sidebar. Folders
|
||||
/// are implicit — they exist because at least one project references
|
||||
/// them via its `folder` field. The "create" action here just seeds
|
||||
/// a new label the user types; it becomes real once any project is
|
||||
/// assigned to it.
|
||||
struct MoveToFolderSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let project: ProjectEntry
|
||||
/// Existing folder labels in the registry, sorted. Computed by
|
||||
/// the caller via `ProjectsViewModel.folders`.
|
||||
let existingFolders: [String]
|
||||
/// Called with the chosen folder. `nil` means "move back to top
|
||||
/// level". Caller wires this through
|
||||
/// `ProjectsViewModel.moveProject(_:toFolder:)`.
|
||||
let onMove: (String?) -> Void
|
||||
|
||||
@State private var mode: Mode
|
||||
@State private var newFolderName: String = ""
|
||||
|
||||
private enum Mode: Hashable {
|
||||
case topLevel
|
||||
case existing(String)
|
||||
case new
|
||||
}
|
||||
|
||||
init(
|
||||
project: ProjectEntry,
|
||||
existingFolders: [String],
|
||||
onMove: @escaping (String?) -> Void
|
||||
) {
|
||||
self.project = project
|
||||
self.existingFolders = existingFolders
|
||||
self.onMove = onMove
|
||||
// Start selection on the project's current folder if any,
|
||||
// otherwise "Top Level". Feels right — Move sheet should
|
||||
// reflect where the project currently lives.
|
||||
if let current = project.folder, existingFolders.contains(current) {
|
||||
_mode = State(initialValue: .existing(current))
|
||||
} else {
|
||||
_mode = State(initialValue: .topLevel)
|
||||
}
|
||||
}
|
||||
|
||||
private var canMove: Bool {
|
||||
switch mode {
|
||||
case .topLevel, .existing:
|
||||
return true
|
||||
case .new:
|
||||
return !newFolderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Move \"\(project.name)\" to folder").font(.headline)
|
||||
Text("Folders only affect how projects are grouped in Scarf's sidebar. Nothing on disk changes.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
Picker("Destination", selection: $mode) {
|
||||
Text("Top Level").tag(Mode.topLevel)
|
||||
if !existingFolders.isEmpty {
|
||||
Section {
|
||||
ForEach(existingFolders, id: \.self) { folder in
|
||||
Text(folder).tag(Mode.existing(folder))
|
||||
}
|
||||
}
|
||||
}
|
||||
Text("New folder…").tag(Mode.new)
|
||||
}
|
||||
.labelsHidden()
|
||||
.pickerStyle(.inline)
|
||||
|
||||
if case .new = mode {
|
||||
TextField("New folder name", text: $newFolderName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit {
|
||||
if canMove { commit() }
|
||||
}
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel") { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Move") { commit() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!canMove)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 420, minHeight: 320)
|
||||
}
|
||||
|
||||
private func commit() {
|
||||
switch mode {
|
||||
case .topLevel:
|
||||
onMove(nil)
|
||||
case .existing(let folder):
|
||||
onMove(folder)
|
||||
case .new:
|
||||
let trimmed = newFolderName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !trimmed.isEmpty else { return }
|
||||
onMove(trimmed)
|
||||
}
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Per-project Sessions tab (v2.3). Lives beside the Dashboard and
|
||||
/// Site tabs in the project view; populated from the session
|
||||
/// attribution sidecar maintained by ChatViewModel. A "New Chat"
|
||||
/// button spawns a fresh ACP session at cwd = project.path and
|
||||
/// routes the user into the Chat feature via AppCoordinator.
|
||||
struct ProjectSessionsView: View {
|
||||
let project: ProjectEntry
|
||||
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
@Environment(HermesFileWatcher.self) private var fileWatcher
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
|
||||
@State private var viewModel: ProjectSessionsViewModel?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
header
|
||||
Divider()
|
||||
content
|
||||
}
|
||||
// `idealHeight: 400` caps what this subtree reports as its
|
||||
// ideal height. Without it, the inner List's row-materialised
|
||||
// intrinsic height bubbles up through NavigationSplitView's
|
||||
// detail slot and, under `.windowResizability(.contentMinSize)`,
|
||||
// opens the window at a height that exceeds the screen on
|
||||
// busy projects — the Sessions tab header + "New Chat" button
|
||||
// end up below the visible desktop edge. `maxHeight: .infinity`
|
||||
// still lets the List fill any taller offered space, and
|
||||
// `minHeight: 0` allows it to shrink. Mirrors the same pattern
|
||||
// applied in RichChatView.
|
||||
.frame(minHeight: 0, idealHeight: 400, maxHeight: .infinity)
|
||||
.task(id: project.id) {
|
||||
// Rebuild the VM when the project changes so stale state
|
||||
// from a previously-selected project doesn't bleed
|
||||
// through.
|
||||
viewModel = ProjectSessionsViewModel(
|
||||
context: serverContext,
|
||||
project: project
|
||||
)
|
||||
await viewModel?.load()
|
||||
}
|
||||
.onChange(of: fileWatcher.lastChangeDate) {
|
||||
Task { await viewModel?.load() }
|
||||
}
|
||||
.onDisappear {
|
||||
// Release the SQLite handle so it doesn't dangle once
|
||||
// the user leaves this tab. `load()` will re-open next
|
||||
// time. Mirrors ActivityView's disappear cleanup.
|
||||
Task { await viewModel?.close() }
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 12) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("Sessions in this project")
|
||||
.font(.headline)
|
||||
Text("Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
Button {
|
||||
// Route into the Chat feature with a cwd override.
|
||||
// ChatView observes this via its onChange and starts
|
||||
// a fresh session with projectPath = our project.
|
||||
coordinator.pendingProjectChat = project.path
|
||||
coordinator.selectedSection = .chat
|
||||
} label: {
|
||||
Label("New Chat", systemImage: "message.badge.filled.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
|
||||
// MARK: - Content
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
if let vm = viewModel {
|
||||
if vm.isLoading && vm.sessions.isEmpty {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if vm.sessions.isEmpty {
|
||||
emptyState(hint: vm.emptyStateHint)
|
||||
} else {
|
||||
sessionList(vm.sessions)
|
||||
}
|
||||
} else {
|
||||
ProgressView()
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
}
|
||||
|
||||
private func emptyState(hint: String?) -> some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "bubble.left.and.bubble.right")
|
||||
.font(.system(size: 36))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(hint ?? "No sessions yet.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.padding(.horizontal, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private func sessionList(_ sessions: [HermesSession]) -> some View {
|
||||
List(sessions) { session in
|
||||
ProjectSessionRow(session: session)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
// Route into the Chat feature with this session
|
||||
// as a resume target. Existing ChatView logic
|
||||
// handles ACP reconnect.
|
||||
coordinator.selectedSessionId = session.id
|
||||
coordinator.selectedSection = .chat
|
||||
}
|
||||
}
|
||||
.listStyle(.plain)
|
||||
}
|
||||
}
|
||||
|
||||
/// Single row in the per-project Sessions list. Intentionally small
|
||||
/// and self-contained so it can evolve independently of the global
|
||||
/// Sessions sidebar's row UI — if the two visualisations diverge
|
||||
/// (e.g. the project tab wants to hide the `source` badge that's
|
||||
/// useful in the global list), they don't pull each other along.
|
||||
private struct ProjectSessionRow: View {
|
||||
let session: HermesSession
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 10) {
|
||||
Image(systemName: iconForSource(session.source))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(displayTitle)
|
||||
.font(.callout)
|
||||
.lineLimit(1)
|
||||
HStack(spacing: 6) {
|
||||
Text(session.id.prefix(12))
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.tertiary)
|
||||
if let started = formattedStart {
|
||||
Text("·")
|
||||
.foregroundStyle(.tertiary)
|
||||
Text(started)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 12)
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("\(session.messageCount)")
|
||||
.font(.caption.monospaced())
|
||||
Text("msgs")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var displayTitle: String {
|
||||
if let t = session.title, !t.isEmpty { return t }
|
||||
return "Untitled session"
|
||||
}
|
||||
|
||||
private var formattedStart: String? {
|
||||
// `startedAt` is `Date?` — the DB column can be null for
|
||||
// sessions in unusual states. Locale-aware short form keeps
|
||||
// us consistent with Insights + Activity.
|
||||
guard let date = session.startedAt else { return nil }
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateStyle = .short
|
||||
formatter.timeStyle = .short
|
||||
return formatter.string(from: date)
|
||||
}
|
||||
|
||||
private func iconForSource(_ source: String) -> String {
|
||||
switch source.lowercased() {
|
||||
case "cli", "acp": return "terminal"
|
||||
case "telegram": return "paperplane"
|
||||
case "discord": return "bubble.left.and.bubble.right"
|
||||
default: return "message"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,275 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Sidebar view for the Projects feature. Renders the registry as:
|
||||
/// - A search field at the top (⌘F focus).
|
||||
/// - Top-level (folder-less) projects.
|
||||
/// - Collapsible DisclosureGroups, one per folder.
|
||||
/// - An "Archived" DisclosureGroup at the bottom, hidden unless the
|
||||
/// Show Archived toggle is on.
|
||||
///
|
||||
/// Selection is bound to `viewModel.selectedProject` so the
|
||||
/// dashboard area stays in sync with clicks anywhere in the hierarchy.
|
||||
/// Context-menu actions delegate back to the parent view via closures
|
||||
/// so the sheets / confirmation dialogs stay co-located with the rest
|
||||
/// of ProjectsView's state.
|
||||
struct ProjectsSidebar: View {
|
||||
@Bindable var viewModel: ProjectsViewModel
|
||||
|
||||
// Predicates hoisted from the parent — avoid reaching down into
|
||||
// service objects from this view.
|
||||
let canConfigureProject: (ProjectEntry) -> Bool
|
||||
let isTemplateInstalled: (ProjectEntry) -> Bool
|
||||
|
||||
// Context-menu + bottom-bar callbacks. Parent owns sheet state
|
||||
// (install, uninstall, rename, move-to-folder, remove-from-list
|
||||
// confirmation dialog) — this view just routes user intent.
|
||||
let onConfigure: (ProjectEntry) -> Void
|
||||
let onUninstallTemplate: (ProjectEntry) -> Void
|
||||
let onRemoveFromList: (ProjectEntry) -> Void
|
||||
let onRename: (ProjectEntry) -> Void
|
||||
let onMoveToFolder: (ProjectEntry) -> Void
|
||||
let onAddProject: () -> Void
|
||||
|
||||
/// Per-view UI state — filter text, show-archived toggle, and
|
||||
/// which folders are expanded. Folder expansion defaults to all
|
||||
/// open so a new user sees everything; they can collapse what
|
||||
/// they don't want.
|
||||
@State private var filterText: String = ""
|
||||
@State private var showArchived: Bool = false
|
||||
@State private var expandedFolders: Set<String> = []
|
||||
@FocusState private var searchFocused: Bool
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
searchField
|
||||
Divider()
|
||||
list
|
||||
Divider()
|
||||
bottomBar
|
||||
}
|
||||
.onAppear {
|
||||
// Start with every folder expanded on first render. If
|
||||
// users collapse, that choice persists for the lifetime
|
||||
// of the view instance (window open).
|
||||
expandedFolders = Set(viewModel.folders)
|
||||
}
|
||||
.onChange(of: viewModel.folders) { _, newFolders in
|
||||
// When a new folder appears (user just moved a project
|
||||
// into one), start it expanded so the move is visibly
|
||||
// reflected.
|
||||
expandedFolders.formUnion(newFolders)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Search
|
||||
|
||||
private var searchField: some View {
|
||||
HStack {
|
||||
Image(systemName: "magnifyingglass")
|
||||
.foregroundStyle(.secondary)
|
||||
.font(.caption)
|
||||
TextField("Filter projects", text: $filterText)
|
||||
.textFieldStyle(.plain)
|
||||
.focused($searchFocused)
|
||||
.font(.caption)
|
||||
if !filterText.isEmpty {
|
||||
Button {
|
||||
filterText = ""
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundStyle(.tertiary)
|
||||
.font(.caption)
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
.padding(.vertical, 6)
|
||||
}
|
||||
|
||||
// MARK: - List
|
||||
|
||||
private var list: some View {
|
||||
List(selection: Binding(
|
||||
get: { viewModel.selectedProject },
|
||||
set: { if let p = $0 { viewModel.selectProject(p) } }
|
||||
)) {
|
||||
// Top-level projects first — matches the Finder-like
|
||||
// mental model where top-level items sit above folders.
|
||||
ForEach(topLevelVisible) { project in
|
||||
projectRow(project)
|
||||
}
|
||||
|
||||
// Per-folder collapsible sections.
|
||||
ForEach(visibleFolders, id: \.self) { folder in
|
||||
let children = folderProjects(folder)
|
||||
if !children.isEmpty {
|
||||
DisclosureGroup(
|
||||
isExpanded: Binding(
|
||||
get: { expandedFolders.contains(folder) },
|
||||
set: { expanded in
|
||||
if expanded {
|
||||
expandedFolders.insert(folder)
|
||||
} else {
|
||||
expandedFolders.remove(folder)
|
||||
}
|
||||
}
|
||||
)
|
||||
) {
|
||||
ForEach(children) { project in
|
||||
projectRow(project)
|
||||
}
|
||||
} label: {
|
||||
Label(folder, systemImage: "folder")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Archived section — only surfaces under the toggle.
|
||||
if showArchived, !archivedVisible.isEmpty {
|
||||
DisclosureGroup {
|
||||
ForEach(archivedVisible) { project in
|
||||
projectRow(project)
|
||||
.opacity(0.7)
|
||||
}
|
||||
} label: {
|
||||
Label("Archived (\(archivedVisible.count))", systemImage: "archivebox")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func projectRow(_ project: ProjectEntry) -> some View {
|
||||
HStack {
|
||||
Image(
|
||||
systemName: viewModel.dashboard != nil
|
||||
&& viewModel.selectedProject == project
|
||||
? "square.grid.2x2.fill"
|
||||
: "square.grid.2x2"
|
||||
)
|
||||
.foregroundStyle(.secondary)
|
||||
Text(project.name)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
}
|
||||
.tag(project)
|
||||
.contextMenu {
|
||||
projectContextMenu(project)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func projectContextMenu(_ project: ProjectEntry) -> some View {
|
||||
if canConfigureProject(project) {
|
||||
Button("Configuration…", systemImage: "slider.horizontal.3") {
|
||||
onConfigure(project)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
Button("Rename…", systemImage: "pencil") { onRename(project) }
|
||||
Button("Move to Folder…", systemImage: "folder") { onMoveToFolder(project) }
|
||||
if project.archived {
|
||||
Button("Unarchive", systemImage: "tray.and.arrow.up") {
|
||||
viewModel.unarchiveProject(project)
|
||||
}
|
||||
} else {
|
||||
Button("Archive", systemImage: "archivebox") {
|
||||
viewModel.archiveProject(project)
|
||||
}
|
||||
}
|
||||
Divider()
|
||||
if isTemplateInstalled(project) {
|
||||
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
|
||||
onUninstallTemplate(project)
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
|
||||
onRemoveFromList(project)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bottom bar
|
||||
|
||||
private var bottomBar: some View {
|
||||
HStack {
|
||||
Button(action: onAddProject) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Add a project")
|
||||
|
||||
Toggle(isOn: $showArchived) {
|
||||
Image(systemName: showArchived ? "archivebox.fill" : "archivebox")
|
||||
.font(.caption)
|
||||
}
|
||||
.toggleStyle(.button)
|
||||
.buttonStyle(.borderless)
|
||||
.help(showArchived ? "Hide archived projects" : "Show archived projects")
|
||||
|
||||
Spacer()
|
||||
|
||||
if let selected = viewModel.selectedProject {
|
||||
Button(action: { onRemoveFromList(selected) }) {
|
||||
Image(systemName: "minus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
|
||||
// MARK: - Derived data
|
||||
|
||||
/// Fuzzy-match on name + path + folder label. Case-insensitive,
|
||||
/// substring — not a true fuzzy search, but matches the project
|
||||
/// count scale (tens, not thousands). Upgradable to a Levenshtein
|
||||
/// scorer later without changing the call sites.
|
||||
private func matches(_ project: ProjectEntry) -> Bool {
|
||||
let needle = filterText
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
.lowercased()
|
||||
guard !needle.isEmpty else { return true }
|
||||
if project.name.lowercased().contains(needle) { return true }
|
||||
if project.path.lowercased().contains(needle) { return true }
|
||||
if let folder = project.folder, folder.lowercased().contains(needle) { return true }
|
||||
return false
|
||||
}
|
||||
|
||||
/// Visible top-level projects (no folder, not archived, passes
|
||||
/// the current filter). Sort is stable by name — the registry
|
||||
/// already preserves insertion order, but showing a sorted list
|
||||
/// of homogeneous top-level entries feels cleaner.
|
||||
private var topLevelVisible: [ProjectEntry] {
|
||||
viewModel.projects
|
||||
.filter { ($0.folder ?? "").isEmpty && !$0.archived && matches($0) }
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
/// Folders that currently have at least one matching, non-
|
||||
/// archived project. Folders with only archived projects move
|
||||
/// into the Archived section's items; empty folders disappear.
|
||||
private var visibleFolders: [String] {
|
||||
viewModel.folders.filter { !folderProjects($0).isEmpty }
|
||||
}
|
||||
|
||||
private func folderProjects(_ folder: String) -> [ProjectEntry] {
|
||||
viewModel.projects
|
||||
.filter { $0.folder == folder && !$0.archived && matches($0) }
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
|
||||
private var archivedVisible: [ProjectEntry] {
|
||||
viewModel.projects
|
||||
.filter { $0.archived && matches($0) }
|
||||
.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending }
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,21 @@ import UniformTypeIdentifiers
|
||||
private enum DashboardTab: String, CaseIterable {
|
||||
case dashboard = "Dashboard"
|
||||
case site = "Site"
|
||||
case sessions = "Sessions"
|
||||
|
||||
var displayName: LocalizedStringResource {
|
||||
switch self {
|
||||
case .dashboard: return "Dashboard"
|
||||
case .site: return "Site"
|
||||
case .sessions: return "Sessions"
|
||||
}
|
||||
}
|
||||
|
||||
var systemImage: String {
|
||||
switch self {
|
||||
case .dashboard: return "square.grid.2x2"
|
||||
case .site: return "globe"
|
||||
case .sessions: return "bubble.left.and.bubble.right"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +45,16 @@ struct ProjectsView: View {
|
||||
/// drop from the registry.
|
||||
@State private var pendingRemoveFromList: ProjectEntry?
|
||||
|
||||
/// Project queued for the rename sheet (v2.3). Sheet state lives
|
||||
/// on the parent view so the sidebar stays a pure presentation
|
||||
/// layer; rename logic routes through `ProjectsViewModel.renameProject`.
|
||||
@State private var renameTarget: ProjectEntry?
|
||||
|
||||
/// Project queued for the move-to-folder sheet (v2.3). Same
|
||||
/// pattern as renameTarget: parent owns sheet state, sidebar
|
||||
/// delegates up.
|
||||
@State private var moveTarget: ProjectEntry?
|
||||
|
||||
private let uninstaller: ProjectTemplateUninstaller
|
||||
|
||||
init(context: ServerContext) {
|
||||
@@ -264,79 +284,47 @@ struct ProjectsView: View {
|
||||
// MARK: - Project List
|
||||
|
||||
private var projectList: some View {
|
||||
VStack(spacing: 0) {
|
||||
List(viewModel.projects, selection: Binding(
|
||||
get: { viewModel.selectedProject },
|
||||
set: { project in
|
||||
if let project {
|
||||
viewModel.selectProject(project)
|
||||
}
|
||||
}
|
||||
)) { project in
|
||||
HStack {
|
||||
Image(systemName: viewModel.dashboard != nil && viewModel.selectedProject == project
|
||||
? "square.grid.2x2.fill" : "square.grid.2x2")
|
||||
.foregroundStyle(.secondary)
|
||||
Text(project.name)
|
||||
}
|
||||
.tag(project)
|
||||
.contextMenu {
|
||||
if isConfigurable(project) {
|
||||
Button("Configuration…", systemImage: "slider.horizontal.3") {
|
||||
configEditorProject = project
|
||||
}
|
||||
}
|
||||
if uninstaller.isTemplateInstalled(project: project) {
|
||||
// "Uninstall Template…" only appears for projects
|
||||
// installed from a `.scarftemplate`. Trailing
|
||||
// ellipsis signals a confirmation sheet follows
|
||||
// (macOS HIG convention); the sheet itself lists
|
||||
// every file/cron/skill that will be removed.
|
||||
Button("Uninstall Template (remove installed files)…", systemImage: "trash") {
|
||||
uninstallerViewModel.begin(project: project)
|
||||
showingUninstallSheet = true
|
||||
}
|
||||
Divider()
|
||||
}
|
||||
// "Remove from List" used to be "Remove from Scarf",
|
||||
// which users read as a full delete. Clarified label +
|
||||
// ellipsis + confirmation dialog all spell out that
|
||||
// this is registry-only; nothing on disk is touched.
|
||||
Button("Remove from List (keep files)…", systemImage: "minus.circle") {
|
||||
pendingRemoveFromList = project
|
||||
}
|
||||
}
|
||||
}
|
||||
.listStyle(.sidebar)
|
||||
|
||||
Divider()
|
||||
HStack {
|
||||
Button(action: { showingAddSheet = true }) {
|
||||
Image(systemName: "plus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
Spacer()
|
||||
if let selected = viewModel.selectedProject {
|
||||
// Route through the same confirmation dialog as the
|
||||
// context-menu "Remove from List" entry. The minus
|
||||
// icon is a drive-by click target right next to "+" —
|
||||
// confirming before mutating the registry stops the
|
||||
// "I clicked by accident and my project's gone" case.
|
||||
Button(action: { pendingRemoveFromList = selected }) {
|
||||
Image(systemName: "minus")
|
||||
}
|
||||
.buttonStyle(.borderless)
|
||||
.help("Remove \(selected.name) from Scarf's project list (files are kept on disk)")
|
||||
}
|
||||
}
|
||||
.padding(8)
|
||||
}
|
||||
// Sidebar is an extracted view; this view stays the owner of
|
||||
// sheet state (add / rename / move / uninstall / remove-from-
|
||||
// list confirmation) and routes intents down as closures.
|
||||
ProjectsSidebar(
|
||||
viewModel: viewModel,
|
||||
canConfigureProject: { isConfigurable($0) },
|
||||
isTemplateInstalled: { uninstaller.isTemplateInstalled(project: $0) },
|
||||
onConfigure: { configEditorProject = $0 },
|
||||
onUninstallTemplate: { project in
|
||||
uninstallerViewModel.begin(project: project)
|
||||
showingUninstallSheet = true
|
||||
},
|
||||
onRemoveFromList: { pendingRemoveFromList = $0 },
|
||||
onRename: { renameTarget = $0 },
|
||||
onMoveToFolder: { moveTarget = $0 },
|
||||
onAddProject: { showingAddSheet = true }
|
||||
)
|
||||
.sheet(isPresented: $showingAddSheet) {
|
||||
AddProjectSheet { name, path in
|
||||
viewModel.addProject(name: name, path: path)
|
||||
fileWatcher.updateProjectWatches(viewModel.dashboardPaths)
|
||||
}
|
||||
}
|
||||
.sheet(item: $renameTarget) { target in
|
||||
RenameProjectSheet(
|
||||
project: target,
|
||||
existingNames: viewModel.projects
|
||||
.filter { $0.name != target.name }
|
||||
.map(\.name)
|
||||
) { newName in
|
||||
viewModel.renameProject(target, to: newName)
|
||||
}
|
||||
}
|
||||
.sheet(item: $moveTarget) { target in
|
||||
MoveToFolderSheet(
|
||||
project: target,
|
||||
existingFolders: viewModel.folders
|
||||
) { newFolder in
|
||||
viewModel.moveProject(target, toFolder: newFolder)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Dashboard Area
|
||||
@@ -356,11 +344,13 @@ struct ProjectsView: View {
|
||||
.padding(.horizontal)
|
||||
.padding(.top)
|
||||
.padding(.bottom, 8)
|
||||
if siteWidget != nil {
|
||||
tabBar
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
// Sessions tab is always present in v2.3, so the tab
|
||||
// bar always renders when a dashboard is loaded.
|
||||
// Site tab filters out when there's no webview widget
|
||||
// (existing v2.2 behavior preserved).
|
||||
tabBar
|
||||
.padding(.horizontal)
|
||||
.padding(.bottom, 8)
|
||||
switch selectedTab {
|
||||
case .dashboard:
|
||||
widgetsTab(dashboard)
|
||||
@@ -370,8 +360,24 @@ struct ProjectsView: View {
|
||||
} else {
|
||||
widgetsTab(dashboard)
|
||||
}
|
||||
case .sessions:
|
||||
if let project = viewModel.selectedProject {
|
||||
ProjectSessionsView(project: project)
|
||||
} else {
|
||||
ContentUnavailableView("No project selected", systemImage: "bubble.left.and.bubble.right")
|
||||
}
|
||||
}
|
||||
}
|
||||
// Clamp the container VStack to the detail column's
|
||||
// offered space. Without it, any tab whose content is
|
||||
// taller than the window (long Sessions list, tall
|
||||
// README block in a dashboard's text widget, etc.) can
|
||||
// bubble its intrinsic height up through
|
||||
// NavigationSplitView's detail slot and push the whole
|
||||
// window past the screen. widgetsTab's own ScrollView
|
||||
// and siteTab's explicit maxHeight both cooperate; the
|
||||
// sessions tab needs this as well.
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
} else if let error = viewModel.dashboardError {
|
||||
ContentUnavailableView {
|
||||
Label("No Dashboard", systemImage: "square.grid.2x2")
|
||||
@@ -395,14 +401,23 @@ struct ProjectsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Tabs that should appear for the current project. `.site` is
|
||||
/// gated on the dashboard actually containing a webview widget,
|
||||
/// per v2.2 behavior — the Site tab is meaningless without one.
|
||||
private var visibleTabs: [DashboardTab] {
|
||||
DashboardTab.allCases.filter { tab in
|
||||
tab != .site || siteWidget != nil
|
||||
}
|
||||
}
|
||||
|
||||
private var tabBar: some View {
|
||||
HStack(spacing: 0) {
|
||||
ForEach(DashboardTab.allCases, id: \.self) { tab in
|
||||
ForEach(visibleTabs, id: \.self) { tab in
|
||||
Button {
|
||||
selectedTab = tab
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
Image(systemName: tab == .dashboard ? "square.grid.2x2" : "globe")
|
||||
Image(systemName: tab.systemImage)
|
||||
.font(.caption)
|
||||
Text(tab.displayName)
|
||||
.font(.subheadline)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Sheet for renaming a project in the registry. Preserves the
|
||||
/// project's `path`, `folder`, and `archived` fields — the rename
|
||||
/// only changes the user-visible name (and therefore the Identifiable
|
||||
/// id). Duplicate-name / empty-name rejection lives in the VM.
|
||||
struct RenameProjectSheet: View {
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
let project: ProjectEntry
|
||||
/// Current set of project names in the registry, used to flag
|
||||
/// duplicates before the user tries to Save. Excludes the
|
||||
/// project being renamed so same-name is a no-op (accepted).
|
||||
let existingNames: [String]
|
||||
/// Called with the trimmed new name. Caller is responsible for
|
||||
/// calling `ProjectsViewModel.renameProject(_:to:)`; this sheet
|
||||
/// just gathers input + validates inline.
|
||||
let onSave: (String) -> Void
|
||||
|
||||
@State private var newName: String
|
||||
|
||||
init(
|
||||
project: ProjectEntry,
|
||||
existingNames: [String],
|
||||
onSave: @escaping (String) -> Void
|
||||
) {
|
||||
self.project = project
|
||||
self.existingNames = existingNames
|
||||
self.onSave = onSave
|
||||
_newName = State(initialValue: project.name)
|
||||
}
|
||||
|
||||
/// Validation for the live input. Empty / whitespace-only / a
|
||||
/// collision with another project's name all disable Save.
|
||||
private var validation: (isValid: Bool, message: String?) {
|
||||
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.isEmpty {
|
||||
return (false, nil) // no error message — just disabled
|
||||
}
|
||||
if trimmed != project.name && existingNames.contains(trimmed) {
|
||||
return (false, String(localized: "A project named \"\(trimmed)\" already exists."))
|
||||
}
|
||||
return (true, nil)
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Rename project").font(.headline)
|
||||
Text("The project directory on disk isn't changed — only the label Scarf shows in the sidebar.")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
TextField("Project name", text: $newName)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.onSubmit {
|
||||
if validation.isValid {
|
||||
save()
|
||||
}
|
||||
}
|
||||
|
||||
if let message = validation.message {
|
||||
Label(message, systemImage: "exclamationmark.triangle.fill")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Button("Cancel") { dismiss() }
|
||||
.keyboardShortcut(.cancelAction)
|
||||
Spacer()
|
||||
Button("Save") { save() }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.disabled(!validation.isValid)
|
||||
}
|
||||
}
|
||||
.padding()
|
||||
.frame(minWidth: 420)
|
||||
}
|
||||
|
||||
private func save() {
|
||||
let trimmed = newName.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
onSave(trimmed)
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,12 @@ import ScarfCore
|
||||
/// Two-column model browser sheet. Left column lists providers, right column
|
||||
/// lists models for the selected provider. Supports filtering and a "Custom…"
|
||||
/// option for free-form model IDs not in the catalog.
|
||||
///
|
||||
/// Overlay-only providers (Nous Portal, OpenAI Codex, Qwen OAuth, …) have no
|
||||
/// models.dev catalog entry, so their right column renders an overlay detail
|
||||
/// view: subscription state for Nous, plus a free-form model-ID field for
|
||||
/// users who know what they want. This is how the picker keeps parity with
|
||||
/// `hermes model` on the CLI, which can reach these providers natively.
|
||||
struct ModelPickerSheet: View {
|
||||
let initialProvider: String
|
||||
let initialModel: String
|
||||
@@ -22,8 +28,21 @@ struct ModelPickerSheet: View {
|
||||
@State private var customModelID: String = ""
|
||||
@State private var customProviderID: String = ""
|
||||
|
||||
// Overlay-provider model entry — distinct from `customMode` because the
|
||||
// provider is pinned; only the model ID is user-editable.
|
||||
@State private var overlayModelID: String = ""
|
||||
|
||||
// Subscription state for the Nous Portal row / detail view. Loaded on
|
||||
// appear; stays in-memory for the life of the sheet.
|
||||
@State private var subscription: NousSubscriptionState = .absent
|
||||
|
||||
/// Drives presentation of the Nous sign-in sheet. Bound to the
|
||||
/// "Sign in to Nous Portal" button in the subscription summary.
|
||||
@State private var showNousSignIn: Bool = false
|
||||
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
private var catalog: ModelCatalogService { ModelCatalogService(context: serverContext) }
|
||||
private var subscriptionService: NousSubscriptionService { NousSubscriptionService(context: serverContext) }
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -45,8 +64,18 @@ struct ModelPickerSheet: View {
|
||||
providers = catalog.loadProviders()
|
||||
selectedProviderID = initialProvider.isEmpty ? (providers.first?.providerID ?? "") : initialProvider
|
||||
selectedModelID = initialModel
|
||||
overlayModelID = initialModel
|
||||
subscription = subscriptionService.loadState()
|
||||
loadModelsForSelection()
|
||||
}
|
||||
.sheet(isPresented: $showNousSignIn) {
|
||||
NousSignInSheet {
|
||||
// Refresh subscription immediately so the right-column
|
||||
// status row flips to "active" without waiting for the
|
||||
// picker to be re-opened.
|
||||
subscription = subscriptionService.loadState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
@@ -81,20 +110,39 @@ struct ModelPickerSheet: View {
|
||||
}
|
||||
)) {
|
||||
ForEach(filteredProviders) { provider in
|
||||
HStack {
|
||||
Text(provider.providerName)
|
||||
Spacer()
|
||||
Text("\(provider.modelCount)")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.tag(provider.providerID)
|
||||
providerRow(provider)
|
||||
.tag(provider.providerID)
|
||||
}
|
||||
}
|
||||
.listStyle(.inset)
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func providerRow(_ provider: HermesProviderInfo) -> some View {
|
||||
HStack(spacing: 6) {
|
||||
Text(provider.providerName)
|
||||
if provider.subscriptionGated {
|
||||
capsuleTag("Subscription", tint: .accentColor)
|
||||
}
|
||||
Spacer()
|
||||
if !provider.isOverlay {
|
||||
Text("\(provider.modelCount)")
|
||||
.font(.caption2.monospaced())
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var modelColumn: some View {
|
||||
if let selected = providers.first(where: { $0.providerID == selectedProviderID }), selected.isOverlay {
|
||||
overlayProviderDetail(selected)
|
||||
} else {
|
||||
cachedModelList
|
||||
}
|
||||
}
|
||||
|
||||
private var cachedModelList: some View {
|
||||
List(selection: $selectedModelID) {
|
||||
ForEach(filteredModels) { model in
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
@@ -139,6 +187,114 @@ struct ModelPickerSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
/// Right-column detail for overlay-only providers (Nous Portal, OpenAI
|
||||
/// Codex, Qwen OAuth, …). models.dev has no catalog for them, so the user
|
||||
/// either trusts Hermes's default (subscription providers) or types a
|
||||
/// model ID they know is valid for the provider's API.
|
||||
@ViewBuilder
|
||||
private func overlayProviderDetail(_ provider: HermesProviderInfo) -> some View {
|
||||
let overlay = catalog.overlayMetadata(for: provider.providerID)
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(alignment: .firstTextBaseline, spacing: 8) {
|
||||
Text(provider.providerName).font(.title3.bold())
|
||||
if provider.subscriptionGated {
|
||||
capsuleTag("Subscription", tint: .accentColor)
|
||||
}
|
||||
}
|
||||
if provider.subscriptionGated {
|
||||
subscriptionSummary(provider: provider, overlay: overlay)
|
||||
} else {
|
||||
Text(overlayInstruction(for: overlay?.authType))
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
Divider()
|
||||
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Model ID").font(.caption).foregroundStyle(.secondary)
|
||||
TextField(modelIDPlaceholder(for: provider), text: $overlayModelID)
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
if provider.subscriptionGated {
|
||||
Text("Leave blank to use Hermes's default Nous model.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
}
|
||||
|
||||
if let docURL = overlay?.docURL, let url = URL(string: docURL) {
|
||||
Link(destination: url) {
|
||||
Label("Setup documentation", systemImage: "book")
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func subscriptionSummary(provider: HermesProviderInfo, overlay: HermesProviderOverlay?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
Text("Paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription — no separate API keys needed.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: subscription.subscribed ? "checkmark.circle.fill" : "exclamationmark.circle")
|
||||
.foregroundStyle(subscription.subscribed ? Color.green : Color.secondary)
|
||||
if subscription.subscribed {
|
||||
Text("Subscription active — active provider is Nous.")
|
||||
} else if subscription.present {
|
||||
Text("Signed in to Nous, but another provider is active.")
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
Text("Not signed in yet.")
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
.font(.callout)
|
||||
|
||||
if !subscription.subscribed {
|
||||
Button {
|
||||
showNousSignIn = true
|
||||
} label: {
|
||||
Label("Sign in to Nous Portal", systemImage: "person.badge.key.fill")
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.regular)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func overlayInstruction(for authType: HermesProviderOverlay.AuthType?) -> String {
|
||||
switch authType {
|
||||
case .oauthExternal:
|
||||
return "Sign in through the provider's OAuth flow — run `hermes auth` from a terminal, then pick the provider to complete sign-in. Back here, set the model ID you want to use."
|
||||
case .externalProcess:
|
||||
return "Uses an external process (e.g. a local agent bridge). Run `hermes auth` from a terminal to complete the link, then set the model ID you want to use."
|
||||
case .oauthDeviceCode:
|
||||
return "Sign in via device-code flow — run `hermes auth` from a terminal and follow the printed URL."
|
||||
default:
|
||||
return "This provider isn't in the models.dev catalog. Enter the model ID you want to use — Hermes will pass it through to the provider verbatim."
|
||||
}
|
||||
}
|
||||
|
||||
private func modelIDPlaceholder(for provider: HermesProviderInfo) -> String {
|
||||
switch provider.providerID {
|
||||
case "nous": return "e.g. hermes-3"
|
||||
case "openai-codex": return "e.g. gpt-5-codex"
|
||||
case "qwen-oauth": return "e.g. qwen3-coder-plus"
|
||||
default: return "e.g. model-name"
|
||||
}
|
||||
}
|
||||
|
||||
private var customEntry: some View {
|
||||
VStack(alignment: .leading, spacing: 12) {
|
||||
Text("Use a model not in the catalog. Hermes accepts any string the provider recognizes, including provider-prefixed forms like \"openrouter/anthropic/claude-opus-4.6\".")
|
||||
@@ -202,14 +358,35 @@ struct ModelPickerSheet: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var isSelectedProviderOverlay: Bool {
|
||||
providers.first(where: { $0.providerID == selectedProviderID })?.isOverlay ?? false
|
||||
}
|
||||
|
||||
private var isSelectedProviderSubscriptionGated: Bool {
|
||||
providers.first(where: { $0.providerID == selectedProviderID })?.subscriptionGated ?? false
|
||||
}
|
||||
|
||||
private var canSubmit: Bool {
|
||||
if customMode {
|
||||
return !customModelID.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
if isSelectedProviderOverlay {
|
||||
// Subscription-gated providers can submit with an empty model ID
|
||||
// (Hermes picks its default). Other overlays require a model ID.
|
||||
if isSelectedProviderSubscriptionGated { return true }
|
||||
return !overlayModelID.trimmingCharacters(in: .whitespaces).isEmpty
|
||||
}
|
||||
return !selectedModelID.isEmpty
|
||||
}
|
||||
|
||||
private var selectedPreview: String? {
|
||||
if isSelectedProviderOverlay {
|
||||
let trimmed = overlayModelID.trimmingCharacters(in: .whitespaces)
|
||||
if trimmed.isEmpty {
|
||||
return selectedProviderID.isEmpty ? nil : "\(selectedProviderID) / (default)"
|
||||
}
|
||||
return "\(selectedProviderID) / \(trimmed)"
|
||||
}
|
||||
guard !selectedModelID.isEmpty, !selectedProviderID.isEmpty else { return nil }
|
||||
return "\(selectedProviderID) / \(selectedModelID)"
|
||||
}
|
||||
@@ -250,18 +427,21 @@ struct ModelPickerSheet: View {
|
||||
let model = customModelID.trimmingCharacters(in: .whitespaces)
|
||||
let provider = resolvedCustomProvider()
|
||||
onSelect(model, provider)
|
||||
} else if isSelectedProviderOverlay {
|
||||
let model = overlayModelID.trimmingCharacters(in: .whitespaces)
|
||||
onSelect(model, selectedProviderID)
|
||||
} else {
|
||||
onSelect(selectedModelID, selectedProviderID)
|
||||
}
|
||||
}
|
||||
|
||||
private func capsuleTag(_ text: String) -> some View {
|
||||
private func capsuleTag(_ text: String, tint: Color = .secondary) -> some View {
|
||||
Text(text)
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.secondary)
|
||||
.foregroundStyle(tint == .secondary ? AnyShapeStyle(.secondary) : AnyShapeStyle(tint))
|
||||
.padding(.horizontal, 5)
|
||||
.padding(.vertical, 1)
|
||||
.background(.quaternary)
|
||||
.background(tint == .secondary ? AnyShapeStyle(.quaternary) : AnyShapeStyle(tint.opacity(0.15)))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
|
||||
/// In-app sign-in sheet for Nous Portal — hosts a ``NousAuthFlow`` and
|
||||
/// renders one of four sub-views keyed on `flow.state`. Reached from the
|
||||
/// model picker's Nous Portal row, the Auxiliary tab's per-task toggle,
|
||||
/// and Credential Pools when the selected provider is `nous`.
|
||||
///
|
||||
/// UX contract with the caller:
|
||||
///
|
||||
/// - Sheet is presented via `.sheet(isPresented:)` from the caller.
|
||||
/// - Parent owns the `isPresented` binding and a `@State var` for the
|
||||
/// dismiss trigger.
|
||||
/// - `onSignedIn` fires on success so the caller can refresh subscription
|
||||
/// state (e.g. re-query ``NousSubscriptionService``) before the sheet
|
||||
/// auto-dismisses ~1.2s later.
|
||||
struct NousSignInSheet: View {
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
/// Fires on `.success`. Callers use this to refresh their cached
|
||||
/// ``NousSubscriptionState`` so the new "Subscription active" chip
|
||||
/// shows immediately without waiting for a full view reload.
|
||||
var onSignedIn: () -> Void = {}
|
||||
|
||||
@State private var flow: NousAuthFlow?
|
||||
@State private var successDismissTask: Task<Void, Never>?
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 16) {
|
||||
header
|
||||
Divider()
|
||||
content
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
.padding(20)
|
||||
.frame(minWidth: 440, idealWidth: 440, minHeight: 340)
|
||||
.onAppear {
|
||||
if flow == nil {
|
||||
let f = NousAuthFlow(context: serverContext)
|
||||
flow = f
|
||||
f.start()
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
successDismissTask?.cancel()
|
||||
flow?.cancel()
|
||||
}
|
||||
.onChange(of: flowState) { _, newValue in
|
||||
if case .success = newValue {
|
||||
onSignedIn()
|
||||
successDismissTask?.cancel()
|
||||
successDismissTask = Task { @MainActor in
|
||||
try? await Task.sleep(nanoseconds: 1_200_000_000)
|
||||
if !Task.isCancelled { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var flowState: NousAuthFlow.State {
|
||||
flow?.state ?? .idle
|
||||
}
|
||||
|
||||
// MARK: - Header
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "person.badge.key.fill")
|
||||
.foregroundStyle(.tint)
|
||||
Text("Sign in to Nous Portal")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
if case .waitingForApproval = flowState {
|
||||
Button("Cancel") { dismiss() }
|
||||
.controlSize(.small)
|
||||
} else if case .starting = flowState {
|
||||
Button("Cancel") { dismiss() }
|
||||
.controlSize(.small)
|
||||
} else {
|
||||
Button("Close") { dismiss() }
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - State-keyed content
|
||||
|
||||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch flowState {
|
||||
case .idle, .starting:
|
||||
startingView
|
||||
case .waitingForApproval(let code, let url):
|
||||
waitingView(userCode: code, verificationURL: url)
|
||||
case .success:
|
||||
successView
|
||||
case .failure(let reason, let billingURL):
|
||||
failureView(reason: reason, billingURL: billingURL)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - .starting
|
||||
|
||||
private var startingView: some View {
|
||||
VStack(spacing: 12) {
|
||||
ProgressView()
|
||||
.controlSize(.large)
|
||||
Text("Contacting Nous Portal…")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
Text("This may take a few seconds.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - .waitingForApproval
|
||||
|
||||
@ViewBuilder
|
||||
private func waitingView(userCode: String, verificationURL: URL) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
VStack(alignment: .leading, spacing: 4) {
|
||||
Text("Approve in your browser")
|
||||
.font(.headline)
|
||||
Text("We opened the Nous Portal approval page. Confirm this code matches what it shows, then approve.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
|
||||
userCodeBadge(userCode)
|
||||
|
||||
HStack(spacing: 12) {
|
||||
Button {
|
||||
NSWorkspace.shared.open(verificationURL)
|
||||
} label: {
|
||||
Label("Open approval page again", systemImage: "safari")
|
||||
}
|
||||
.controlSize(.small)
|
||||
Spacer()
|
||||
HStack(spacing: 6) {
|
||||
ProgressView().controlSize(.small)
|
||||
Text("Waiting for approval…")
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private func userCodeBadge(_ code: String) -> some View {
|
||||
HStack(spacing: 10) {
|
||||
Text(code)
|
||||
.font(.system(size: 28, weight: .semibold, design: .monospaced))
|
||||
.textSelection(.enabled)
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.vertical, 10)
|
||||
.background(.quaternary.opacity(0.5), in: RoundedRectangle(cornerRadius: 8))
|
||||
Button {
|
||||
copyToPasteboard(code)
|
||||
} label: {
|
||||
Label("Copy", systemImage: "doc.on.doc")
|
||||
}
|
||||
.controlSize(.small)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - .success
|
||||
|
||||
private var successView: some View {
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.green)
|
||||
.font(.system(size: 48))
|
||||
Text("Signed in to Nous Portal")
|
||||
.font(.headline)
|
||||
Text("Your tools will now route through your subscription.")
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
}
|
||||
|
||||
// MARK: - .failure
|
||||
|
||||
@ViewBuilder
|
||||
private func failureView(reason: String, billingURL: URL?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 14) {
|
||||
HStack(spacing: 8) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.foregroundStyle(.orange)
|
||||
Text(billingURL == nil ? "Sign-in didn't complete" : "Subscription required")
|
||||
.font(.headline)
|
||||
}
|
||||
|
||||
Text(reason)
|
||||
.font(.callout)
|
||||
.foregroundStyle(.secondary)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
.textSelection(.enabled)
|
||||
|
||||
if let billingURL {
|
||||
Button {
|
||||
NSWorkspace.shared.open(billingURL)
|
||||
} label: {
|
||||
Label("Subscribe", systemImage: "creditcard")
|
||||
.frame(maxWidth: .infinity)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
.controlSize(.large)
|
||||
}
|
||||
|
||||
HStack(spacing: 10) {
|
||||
Button("Try again") { flow?.start() }
|
||||
.buttonStyle(.bordered)
|
||||
Button("Copy error") {
|
||||
let payload = (flow?.output.isEmpty == false) ? flow!.output : reason
|
||||
copyToPasteboard(payload)
|
||||
}
|
||||
.buttonStyle(.bordered)
|
||||
Spacer()
|
||||
Button("Close") { dismiss() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
private func copyToPasteboard(_ value: String) {
|
||||
let pb = NSPasteboard.general
|
||||
pb.clearContents()
|
||||
pb.setString(value, forType: .string)
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Advanced tab — network, compression, checkpoints, logging, delegation, file read cap,
|
||||
/// cron wrap, config diagnostics, backup/restore, paths, raw config.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Agent tab — turns, reasoning effort, tool use enforcement, approvals, gateway timing, service tier.
|
||||
struct AgentTab: View {
|
||||
@@ -16,7 +17,7 @@ struct AgentTab: View {
|
||||
StepperRow(label: "Approval Timeout (s)", value: viewModel.config.approvalTimeout, range: 5...600, step: 5) { viewModel.setApprovalTimeout($0) }
|
||||
}
|
||||
|
||||
SettingsSection(title: "Gateway", icon: "antenna.radiowaves.left.and.right") {
|
||||
SettingsSection(title: "Messaging Gateway", icon: "antenna.radiowaves.left.and.right") {
|
||||
ToggleRow(label: "Fast Mode", isOn: viewModel.config.serviceTier == "fast") { on in
|
||||
viewModel.setServiceTier(on ? "fast" : "normal")
|
||||
}
|
||||
|
||||
@@ -3,9 +3,19 @@ import ScarfCore
|
||||
|
||||
/// Auxiliary tab — the 8 sub-model tasks hermes delegates to cheaper models.
|
||||
/// Each follows the same provider/model/base_url/api_key/timeout pattern.
|
||||
///
|
||||
/// Adds a per-task **Route through Nous Portal** toggle for Hermes v0.10.0+
|
||||
/// subscribers. The toggle flips `auxiliary.<task>.provider` between `nous`
|
||||
/// (subscription-routed) and `auto` (inherit main provider) — Hermes derives
|
||||
/// the gateway routing from that single field; there is no separate
|
||||
/// `use_gateway` key to write.
|
||||
struct AuxiliaryTab: View {
|
||||
@Bindable var viewModel: SettingsViewModel
|
||||
|
||||
@Environment(\.serverContext) private var serverContext
|
||||
@State private var subscription: NousSubscriptionState = .absent
|
||||
@State private var showNousSignIn: Bool = false
|
||||
|
||||
// Keyed by the config path name — matches `auxiliary.<task>.*` in config.yaml.
|
||||
private let tasks: [(key: String, title: LocalizedStringKey, icon: String)] = [
|
||||
("vision", "Vision", "eye"),
|
||||
@@ -29,11 +39,21 @@ struct AuxiliaryTab: View {
|
||||
auxRows(for: task.key)
|
||||
}
|
||||
}
|
||||
Color.clear.frame(height: 0)
|
||||
.onAppear {
|
||||
subscription = NousSubscriptionService(context: serverContext).loadState()
|
||||
}
|
||||
.sheet(isPresented: $showNousSignIn) {
|
||||
NousSignInSheet {
|
||||
subscription = NousSubscriptionService(context: serverContext).loadState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func auxRows(for key: String) -> some View {
|
||||
let model = auxModel(for: key)
|
||||
nousGatewayToggle(for: key, currentProvider: model.provider)
|
||||
EditableTextField(label: "Provider", value: model.provider) { viewModel.setAuxiliary(key, field: "provider", value: $0) }
|
||||
EditableTextField(label: "Model", value: model.model) { viewModel.setAuxiliary(key, field: "model", value: $0) }
|
||||
EditableTextField(label: "Base URL", value: model.baseURL) { viewModel.setAuxiliary(key, field: "base_url", value: $0) }
|
||||
@@ -41,6 +61,30 @@ struct AuxiliaryTab: View {
|
||||
StepperRow(label: "Timeout (s)", value: model.timeout, range: 5...3600, step: 5) { viewModel.setAuxiliaryTimeout(key, value: $0) }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func nousGatewayToggle(for key: String, currentProvider: String) -> some View {
|
||||
let isOn = (currentProvider == "nous")
|
||||
ToggleRow(label: "Nous Portal", isOn: isOn) { wantsOn in
|
||||
// "nous" enables subscription routing; "auto" reverts to the
|
||||
// inherit-main-provider default. We never touch model/base/key
|
||||
// fields here — Hermes reuses them if the user switches back.
|
||||
viewModel.setAuxiliary(key, field: "provider", value: wantsOn ? "nous" : "auto")
|
||||
}
|
||||
if !subscription.present && !isOn {
|
||||
HStack(spacing: 8) {
|
||||
Text("Requires an active Nous Portal subscription.")
|
||||
.font(.caption2)
|
||||
.foregroundStyle(.tertiary)
|
||||
Button("Sign in first") { showNousSignIn = true }
|
||||
.controlSize(.mini)
|
||||
.buttonStyle(.borderedProminent)
|
||||
Spacer()
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
}
|
||||
|
||||
private func auxModel(for key: String) -> AuxiliaryModel {
|
||||
switch key {
|
||||
case "vision": return viewModel.config.auxiliary.vision
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Browser tab — browser backend + automation timeouts + camofox.
|
||||
struct BrowserTab: View {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Display tab — streaming, reasoning, cost, skin, compact mode, inline diffs, bell, etc.
|
||||
struct DisplayTab: View {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// General tab — model picker (provider auto-follows), personality, locale.
|
||||
/// Credential management lives in the Credential Pools sidebar item; a hint
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Memory tab — built-in memory settings + external provider picker.
|
||||
struct MemoryTab: View {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Security tab — redaction, command allowlist (read-only), Tirith sandbox, website blocklist, human delay.
|
||||
struct SecurityTab: View {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Terminal tab — backend plus docker/container options.
|
||||
/// Heavy docker/container settings are hidden unless a container backend is selected.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
/// Voice tab — push-to-talk + TTS + STT provider settings.
|
||||
struct VoiceTab: View {
|
||||
|
||||
@@ -24,6 +24,20 @@ struct TemplateConfigSheet: View {
|
||||
header
|
||||
Divider()
|
||||
ScrollView {
|
||||
// `.frame(maxWidth: .infinity, alignment: .leading)` is
|
||||
// load-bearing: without it, SwiftUI resolves width
|
||||
// bottom-up and an unbreakable token in a child (e.g. a
|
||||
// raw URL inside a field description rendered via
|
||||
// AttributedString markdown) sets the whole VStack's
|
||||
// ideal width to that token's length. ScrollView's
|
||||
// content then exceeds the sheet's viewport, the outer
|
||||
// `.frame(minWidth: 560)` grows to content width, and
|
||||
// the window clips the result with labels cut off on
|
||||
// the left + URL spilling off the right. With the
|
||||
// explicit maxWidth, the ScrollView's offered width
|
||||
// propagates down and the description Text's
|
||||
// `.fixedSize(horizontal: false, vertical: true)`
|
||||
// wraps at whitespace boundaries as intended.
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
if viewModel.schema.fields.isEmpty {
|
||||
ContentUnavailableView(
|
||||
@@ -41,6 +55,7 @@ struct TemplateConfigSheet: View {
|
||||
modelRecommendation(rec)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(20)
|
||||
}
|
||||
Divider()
|
||||
@@ -117,7 +132,11 @@ struct TemplateConfigSheet: View {
|
||||
// Inline markdown so descriptions can include
|
||||
// `[Create one](https://…)`-style links to token
|
||||
// generation pages, **bold** emphasis on important
|
||||
// prerequisites, etc.
|
||||
// prerequisites, etc. Raw URLs (not wrapped in
|
||||
// markdown link syntax) will still render but can't
|
||||
// word-break mid-token — keep the parent maxWidth
|
||||
// constraint below so a rogue raw URL wraps cleanly
|
||||
// instead of expanding the entire sheet.
|
||||
TemplateMarkdown.inlineText(description)
|
||||
.font(.caption)
|
||||
.foregroundStyle(.secondary)
|
||||
@@ -130,6 +149,12 @@ struct TemplateConfigSheet: View {
|
||||
.foregroundStyle(.red)
|
||||
}
|
||||
}
|
||||
// maxWidth: .infinity forces this row to span the column's
|
||||
// full width so its internal description Text wraps instead
|
||||
// of expanding the outer VStack when a description contains
|
||||
// a long unbreakable token (raw URL, path, etc.). See the
|
||||
// comment on the parent ScrollView's inner VStack.
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(12)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 8)
|
||||
@@ -288,24 +313,23 @@ private struct EnumControl: View {
|
||||
let options: [TemplateConfigField.EnumOption]
|
||||
@Binding var value: String
|
||||
var body: some View {
|
||||
// Segmented for ≤ 4 options, dropdown otherwise — fits Scarf's
|
||||
// existing settings UI.
|
||||
if options.count <= 4 {
|
||||
Picker("", selection: $value) {
|
||||
ForEach(options) { opt in
|
||||
Text(opt.label).tag(opt.value)
|
||||
}
|
||||
// Always use the default Menu picker (dropdown). An earlier
|
||||
// version switched to `.pickerStyle(.segmented)` when
|
||||
// `options.count ≤ 4` for a more compact look, but on macOS
|
||||
// segmented pickers size to the intrinsic width of all their
|
||||
// labels concatenated — they refuse offered width constraints
|
||||
// and refuse to wrap. A schema with three long labels like
|
||||
// "Claude Opus 4 (Recommended - Most Capable)" produced a
|
||||
// ~650pt picker that overflowed the 560pt sheet viewport,
|
||||
// clipping the entire form. Menu pickers respect the fieldRow's
|
||||
// offered width and show long labels in the popup list, so the
|
||||
// sheet can't overflow regardless of label length.
|
||||
Picker("", selection: $value) {
|
||||
ForEach(options) { opt in
|
||||
Text(opt.label).tag(opt.value)
|
||||
}
|
||||
.pickerStyle(.segmented)
|
||||
.labelsHidden()
|
||||
} else {
|
||||
Picker("", selection: $value) {
|
||||
ForEach(options) { opt in
|
||||
Text(opt.label).tag(opt.value)
|
||||
}
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
.labelsHidden()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import SwiftUI
|
||||
import AppKit
|
||||
import UniformTypeIdentifiers
|
||||
import ScarfCore
|
||||
|
||||
/// Author-facing sheet for exporting an existing project as a
|
||||
/// `.scarftemplate`. Mirrors the profile-export flow: fill in a few fields,
|
||||
|
||||
@@ -127,6 +127,16 @@ struct TemplateInstallSheet: View {
|
||||
.padding(.bottom, 8)
|
||||
Divider()
|
||||
ScrollView {
|
||||
// `.frame(maxWidth: .infinity, alignment: .leading)` —
|
||||
// without it, a subsection containing an unbreakable
|
||||
// token (raw URL in a cron prompt or README block, a
|
||||
// long file path in the project-files list, a schema
|
||||
// description with a bare URL, etc.) sets the VStack's
|
||||
// ideal width to that token's length; the sheet grows
|
||||
// past its `.frame(minWidth: 620)` and gets clipped by
|
||||
// the window. Same fix as `TemplateConfigSheet`'s
|
||||
// inner VStack — propagate the ScrollView's width down
|
||||
// so inner Text wraps instead of expanding outward.
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
projectFilesSection(plan: plan)
|
||||
if plan.skillsNamespaceDir != nil {
|
||||
@@ -143,6 +153,7 @@ struct TemplateInstallSheet: View {
|
||||
}
|
||||
readmeSection
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.vertical)
|
||||
}
|
||||
Divider()
|
||||
|
||||
@@ -889,6 +889,10 @@
|
||||
},
|
||||
"••••••••••" : {
|
||||
|
||||
},
|
||||
"`%@` uses a different sign-in flow." : {
|
||||
"comment" : "A description of the sign-in flow for a given provider.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"+ %lld more…" : {
|
||||
"comment" : "A button that shows the number of files that were left behind by the template uninstaller.",
|
||||
@@ -1028,6 +1032,10 @@
|
||||
"comment" : "A message that appears when a memory block is no longer present in MEMORY.md.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"A project named \"%@\" already exists." : {
|
||||
"comment" : "A warning message that appears in a Rename Project sheet if the user-provided name is a duplicate of an existing project. The argument is the name of the duplicate project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"A QR code will appear below. Scan it with WhatsApp on your phone. The session is saved to ~/.hermes/platforms/whatsapp/ so you won't need to scan again after restarts." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -1391,6 +1399,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Add a project" : {
|
||||
"comment" : "A button that adds a new project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Add a project folder to get started. Create a .scarf/dashboard.json file in your project to define widgets." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -2480,6 +2492,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Approve in your browser" : {
|
||||
|
||||
},
|
||||
"Archive" : {
|
||||
"localizations" : {
|
||||
@@ -2521,6 +2536,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Archived (%lld)" : {
|
||||
"comment" : "A label that opens a group of archived projects.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Args (one per line)" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3745,6 +3764,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Chat · %@" : {
|
||||
"comment" : "A label that shows the name of the active Scarf project, followed by \"Chat\".",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Chat is scoped to Scarf project \"%@\"" : {
|
||||
"comment" : "Tooltip for the folder-chip indicator.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Chat Messages" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -3785,6 +3812,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Chats you start here get attributed automatically. Older CLI-started sessions live in the global Sessions sidebar." : {
|
||||
"comment" : "A description of the purpose of the Sessions tab.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Check" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -5246,6 +5277,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Contacting Nous Portal…" : {
|
||||
|
||||
},
|
||||
"Container Limits" : {
|
||||
"localizations" : {
|
||||
@@ -5530,6 +5564,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Copy error" : {
|
||||
|
||||
},
|
||||
"Copy error details" : {
|
||||
"localizations" : {
|
||||
@@ -6874,6 +6911,10 @@
|
||||
},
|
||||
"Description" : {
|
||||
|
||||
},
|
||||
"Destination" : {
|
||||
"comment" : "A label for the folder picker in the move-to-folder sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Details" : {
|
||||
"localizations" : {
|
||||
@@ -8802,6 +8843,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Filter projects" : {
|
||||
"comment" : "A label for a search field in the sidebar.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Filter servers..." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -9006,6 +9051,10 @@
|
||||
"comment" : "A placeholder for a comma-separated list of tags.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Folders only affect how projects are grouped in Scarf's sidebar. Nothing on disk changes." : {
|
||||
"comment" : "A description of how folders affect project grouping.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Full copy of active profile (all state)" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -9047,6 +9096,7 @@
|
||||
}
|
||||
},
|
||||
"Gateway" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -9127,6 +9177,7 @@
|
||||
}
|
||||
},
|
||||
"Gateway Running" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -9167,6 +9218,7 @@
|
||||
}
|
||||
},
|
||||
"Gateway Stopped" : {
|
||||
"extractionState" : "stale",
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
"stringUnit" : {
|
||||
@@ -9698,6 +9750,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Hide archived projects" : {
|
||||
"comment" : "A toggle that hides archived projects.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Hide details" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -10958,6 +11014,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Leave blank to use Hermes's default Nous model." : {
|
||||
"comment" : "A description of the default Nous model.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Leave blank unless Hermes is installed at a non-default path (systemd services often live at /var/lib/hermes/.hermes; Docker sidecars vary). Test Connection auto-suggests a value when it detects one of the known alternates." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -11902,6 +11962,18 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Messaging Gateway" : {
|
||||
"comment" : "The title of the messaging gateway view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Messaging Gateway Running" : {
|
||||
"comment" : "A label that indicates that the messaging gateway is running.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Messaging Gateway Stopped" : {
|
||||
"comment" : "A label that describes the messaging gateway as stopped.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Metadata" : {
|
||||
"comment" : "A heading for the metadata section of the template export sheet.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -12186,6 +12258,22 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Move" : {
|
||||
"comment" : "A button that moves a project to a folder.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Move \"%@\" to folder" : {
|
||||
"comment" : "A heading for a dialog that lets the user move a project to a folder.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Move to Folder…" : {
|
||||
"comment" : "A context menu action that moves a project to a folder.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"msgs" : {
|
||||
"comment" : "A label for the number of messages in a session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"my_server" : {
|
||||
|
||||
},
|
||||
@@ -12309,6 +12397,17 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"New Chat" : {
|
||||
"comment" : "A button that starts a new chat session.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"New folder name" : {
|
||||
|
||||
},
|
||||
"New folder…" : {
|
||||
"comment" : "A label for a new folder name.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"New name for '%@'" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -13327,6 +13426,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"No project selected" : {
|
||||
"comment" : "A label that indicates that no project is selected.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"No Projects" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -13734,6 +13837,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Not signed in yet." : {
|
||||
"comment" : "A description of a model picker sheet's subscription",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Notable Sessions" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -13774,6 +13881,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Nous Portal uses a dedicated sign-in flow." : {
|
||||
"comment" : "A description of the process of using the Nous Portal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"npx" : {
|
||||
|
||||
},
|
||||
@@ -13867,6 +13978,10 @@
|
||||
"comment" : "A placeholder for a template's description.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Open approval page again" : {
|
||||
"comment" : "A button that opens a web page.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Open BotFather" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -14559,6 +14674,10 @@
|
||||
"comment" : "A label for the template's owner and name.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Paid Nous Portal subscribers route web search, image generation, TTS, and browser automation through their subscription — no separate API keys needed." : {
|
||||
"comment" : "A description of the benefits of using a Nous",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Pair Device" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -15458,6 +15577,10 @@
|
||||
},
|
||||
"Project folder kept" : {
|
||||
|
||||
},
|
||||
"Project name" : {
|
||||
"comment" : "A label for a text field that lets the user enter a project name.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Project Name" : {
|
||||
"localizations" : {
|
||||
@@ -16870,6 +16993,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Rename project" : {
|
||||
"comment" : "A title for a sheet that renames a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Rename Session" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -16949,6 +17076,9 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Rename…" : {
|
||||
|
||||
},
|
||||
"required" : {
|
||||
|
||||
@@ -17036,6 +17166,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Requires an active Nous Portal subscription." : {
|
||||
"comment" : "A message that appears when the Nous Portal subscription is not active.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Requires: %@" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -17716,6 +17850,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Run `hermes auth add %@` in a terminal to finish sign-in. In-app support for this provider is coming in a follow-up." : {
|
||||
"comment" : "A description of the CLI fallback for a given provider.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Run `hermes memory setup` in Terminal for full provider configuration." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -19379,6 +19517,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Sessions in this project" : {
|
||||
"comment" : "A heading for the list of sessions in a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Set as default — open this server when Scarf launches." : {
|
||||
"comment" : "A tooltip for the star button in the Manage Servers view.",
|
||||
"isCommentAutoGenerated" : true
|
||||
@@ -19463,6 +19605,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Setup documentation" : {
|
||||
"comment" : "A link to a documentation page for setting up a",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Share Debug Report…" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -19623,6 +19769,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Show archived projects" : {
|
||||
"comment" : "A toggle that shows/hides archived projects.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Show details" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -19790,6 +19940,17 @@
|
||||
"comment" : "A hint for the user on how to show/hide the secret.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Sign in first" : {
|
||||
"comment" : "A button that opens a sheet for signing in to a Nous Portal subscription.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Sign in to Nous Portal" : {
|
||||
|
||||
},
|
||||
"Sign-in didn't complete" : {
|
||||
"comment" : "A title for a failed sign-in attempt.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Signal integration requires signal-cli (Java-based) installed locally. Link this Mac as a Signal device, then keep the daemon running so hermes can send/receive messages." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -19915,6 +20076,13 @@
|
||||
},
|
||||
"signal-cli Terminal" : {
|
||||
|
||||
},
|
||||
"Signed in to Nous Portal" : {
|
||||
|
||||
},
|
||||
"Signed in to Nous, but another provider is active." : {
|
||||
"comment" : "A description of a user's subscription to Nous, but",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"SILENT" : {
|
||||
|
||||
@@ -20980,6 +21148,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Subscription active — active provider is Nous." : {
|
||||
"comment" : "A description of a user's active subscription to",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Subscription required" : {
|
||||
|
||||
},
|
||||
"Succeeded" : {
|
||||
"localizations" : {
|
||||
@@ -21435,6 +21610,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"The project directory on disk isn't changed — only the label Scarf shows in the sidebar." : {
|
||||
"comment" : "A description of the project name field.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"The remote's SSH fingerprint no longer matches what your `~/.ssh/known_hosts` file expected. This usually means the remote was reinstalled — or, less commonly, that someone is intercepting the connection." : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -21643,6 +21822,10 @@
|
||||
"comment" : "A description of the local machine.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"This may take a few seconds." : {
|
||||
"comment" : "A description of the time it takes to connect to Nous Portal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"This project wasn't installed from a schemaful template." : {
|
||||
|
||||
},
|
||||
@@ -22422,6 +22605,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Top Level" : {
|
||||
"comment" : "A folder in the sidebar.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Top Tools" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -22462,6 +22649,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Try again" : {
|
||||
"comment" : "A button that triggers a re-attempt to sign in to Nous Portal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"TTS Off" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -22582,6 +22773,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Unarchive" : {
|
||||
"comment" : "A button that unarchives a project.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Uninstall" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -23409,6 +23604,10 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"Waiting for approval…" : {
|
||||
"comment" : "A label displayed in the `.waitingForApproval` state of the",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Waiting for authorization URL…" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -23529,6 +23728,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"We opened the Nous Portal approval page. Confirm this code matches what it shows, then approve." : {
|
||||
"comment" : "A description of the user's task to approve a",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"We'll open the Nous Portal approval page in your browser and show the device code here. No code-paste step." : {
|
||||
"comment" : "A description of the process of adding a credential via the Nous Portal.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Web Extract" : {
|
||||
"localizations" : {
|
||||
"de" : {
|
||||
@@ -23951,6 +24158,10 @@
|
||||
"Your name" : {
|
||||
"comment" : "A label for the user's name.",
|
||||
"isCommentAutoGenerated" : true
|
||||
},
|
||||
"Your tools will now route through your subscription." : {
|
||||
"comment" : "A description of the success state of the",
|
||||
"isCommentAutoGenerated" : true
|
||||
}
|
||||
},
|
||||
"version" : "1.1"
|
||||
|
||||
@@ -50,7 +50,7 @@ enum SidebarSection: String, CaseIterable, Identifiable {
|
||||
case .profiles: return "Profiles"
|
||||
case .tools: return "Tools"
|
||||
case .mcpServers: return "MCP Servers"
|
||||
case .gateway: return "Gateway"
|
||||
case .gateway: return "Messaging Gateway"
|
||||
case .cron: return "Cron"
|
||||
case .health: return "Health"
|
||||
case .logs: return "Logs"
|
||||
@@ -91,4 +91,15 @@ final class AppCoordinator {
|
||||
var selectedSection: SidebarSection = .dashboard
|
||||
var selectedSessionId: String?
|
||||
var selectedProjectName: String?
|
||||
|
||||
/// When non-nil, ChatView should start a fresh ACP session with
|
||||
/// this absolute project path as cwd and then clear the value.
|
||||
/// Wired from the per-project Sessions tab's "New Chat" button
|
||||
/// (v2.3): the tab sets this, switches `selectedSection` to
|
||||
/// `.chat`, and ChatView reacts on its next render.
|
||||
///
|
||||
/// Separate from `selectedSessionId` (which resumes an existing
|
||||
/// session) — a new session needs a cwd override Scarf doesn't
|
||||
/// yet have an id for.
|
||||
var pendingProjectChat: String?
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import SwiftUI
|
||||
import ScarfCore
|
||||
|
||||
struct SidebarView: View {
|
||||
@Environment(AppCoordinator.self) private var coordinator
|
||||
|
||||
@@ -95,6 +95,19 @@ struct ScarfApp: App {
|
||||
registry.defaultServerID
|
||||
}
|
||||
.defaultSize(width: 1100, height: 700)
|
||||
// Without an explicit resizability, `WindowGroup` defaults to
|
||||
// `.automatic` which on macOS evaluates to `.contentSize` —
|
||||
// meaning the window is BOUND to its content's ideal size
|
||||
// rather than bounded-below by it. Any section whose content's
|
||||
// intrinsic height changes (Chat's message list, the v2.3
|
||||
// per-project Sessions tab, Insights charts) would resize the
|
||||
// window on every section switch, snap back against user
|
||||
// resize, and sometimes push the whole window past the
|
||||
// screen. `.contentMinSize` turns the content's ideal height
|
||||
// into a minimum floor: user resize works freely, the window
|
||||
// stays put across section switches, and it still can't shrink
|
||||
// smaller than a section's minimum render.
|
||||
.windowResizability(.contentMinSize)
|
||||
.commands {
|
||||
CommandGroup(after: .appInfo) {
|
||||
Button("Check for Updates…") { updater.checkForUpdates() }
|
||||
@@ -374,7 +387,7 @@ struct MenuBarMenu: View {
|
||||
systemImage: status.hermesRunning ? "circle.fill" : "circle"
|
||||
)
|
||||
Label(
|
||||
status.gatewayRunning ? "Gateway Running" : "Gateway Stopped",
|
||||
status.gatewayRunning ? "Messaging Gateway Running" : "Messaging Gateway Stopped",
|
||||
systemImage: status.gatewayRunning ? "circle.fill" : "circle"
|
||||
)
|
||||
Button("Start Hermes") { status.startHermes() }
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import scarf
|
||||
|
||||
/// Tests that ``CredentialPoolsOAuthGate`` steers each known provider to
|
||||
/// the right OAuth flow. The regression this prevents: a user hitting the
|
||||
/// "Start OAuth" button for nous / openai-codex / qwen-oauth /
|
||||
/// google-gemini-cli / copilot-acp and watching the UI stall silently.
|
||||
@Suite struct CredentialPoolsGatingTests {
|
||||
|
||||
/// Synthesize a ModelCatalogService over a minimal fixture cache so
|
||||
/// tests don't depend on the live `~/.hermes/models_dev_cache.json`.
|
||||
private func makeCatalog() throws -> ModelCatalogService {
|
||||
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-cpgate-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let path = dir.appendingPathComponent("models_dev_cache.json").path
|
||||
// Include anthropic so the .ok path has a recognizable provider.
|
||||
let json = """
|
||||
{
|
||||
"anthropic": {
|
||||
"name": "Anthropic",
|
||||
"models": { "claude-sonnet-4-5": { "name": "Claude Sonnet 4.5" } }
|
||||
}
|
||||
}
|
||||
"""
|
||||
try json.write(toFile: path, atomically: true, encoding: .utf8)
|
||||
return ModelCatalogService(path: path)
|
||||
}
|
||||
|
||||
@Test func nousRoutesToDedicatedSignInFlow() throws {
|
||||
let catalog = try makeCatalog()
|
||||
#expect(CredentialPoolsOAuthGate.resolve(providerID: "nous", catalog: catalog) == .useNousSignIn)
|
||||
// Whitespace + case insensitivity should also work — users who type
|
||||
// "Nous " shouldn't fall through to the generic flow.
|
||||
#expect(CredentialPoolsOAuthGate.resolve(providerID: " Nous ", catalog: catalog) == .useNousSignIn)
|
||||
}
|
||||
|
||||
@Test func deviceCodeAndExternalProvidersRouteToCLI() throws {
|
||||
let catalog = try makeCatalog()
|
||||
// `openai-codex` is .oauthExternal in the overlay table.
|
||||
if case .useCLI(let provider) = CredentialPoolsOAuthGate.resolve(providerID: "openai-codex", catalog: catalog) {
|
||||
#expect(provider == "openai-codex")
|
||||
} else {
|
||||
Issue.record("openai-codex should route to .useCLI")
|
||||
}
|
||||
// `qwen-oauth` is .oauthExternal.
|
||||
if case .useCLI = CredentialPoolsOAuthGate.resolve(providerID: "qwen-oauth", catalog: catalog) {
|
||||
// ok
|
||||
} else {
|
||||
Issue.record("qwen-oauth should route to .useCLI")
|
||||
}
|
||||
// `google-gemini-cli` is .oauthExternal.
|
||||
if case .useCLI = CredentialPoolsOAuthGate.resolve(providerID: "google-gemini-cli", catalog: catalog) {
|
||||
// ok
|
||||
} else {
|
||||
Issue.record("google-gemini-cli should route to .useCLI")
|
||||
}
|
||||
// `copilot-acp` is .externalProcess.
|
||||
if case .useCLI = CredentialPoolsOAuthGate.resolve(providerID: "copilot-acp", catalog: catalog) {
|
||||
// ok
|
||||
} else {
|
||||
Issue.record("copilot-acp should route to .useCLI")
|
||||
}
|
||||
}
|
||||
|
||||
@Test func pkceProvidersPassThroughAsOK() throws {
|
||||
let catalog = try makeCatalog()
|
||||
// Anthropic is a standard PKCE provider in Hermes — must not be gated.
|
||||
#expect(CredentialPoolsOAuthGate.resolve(providerID: "anthropic", catalog: catalog) == .ok)
|
||||
}
|
||||
|
||||
@Test func unknownProvidersDefaultToOK() throws {
|
||||
let catalog = try makeCatalog()
|
||||
// Providers we don't know about shouldn't be blocked — users with
|
||||
// custom setups need the escape hatch.
|
||||
#expect(CredentialPoolsOAuthGate.resolve(providerID: "custom-provider-xyz", catalog: catalog) == .ok)
|
||||
}
|
||||
|
||||
@Test func emptyProviderReturnsProviderEmpty() throws {
|
||||
let catalog = try makeCatalog()
|
||||
#expect(CredentialPoolsOAuthGate.resolve(providerID: "", catalog: catalog) == .providerEmpty)
|
||||
#expect(CredentialPoolsOAuthGate.resolve(providerID: " ", catalog: catalog) == .providerEmpty)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
@testable import scarf
|
||||
|
||||
/// Unit tests for the pure parsers in ``NousAuthFlow``. The subprocess side
|
||||
/// of the flow is covered by manual end-to-end testing against a live
|
||||
/// hermes install — parser behavior is what we can pin here.
|
||||
@Suite struct NousAuthFlowParserTests {
|
||||
|
||||
// MARK: - Device-code block
|
||||
|
||||
@Test func parsesVerificationURLAndUserCode() throws {
|
||||
let text = """
|
||||
Requesting device code from Nous Portal...
|
||||
|
||||
To continue:
|
||||
1. Open: https://portal.nousresearch.com/device/ABCD-EFGH
|
||||
2. If prompted, enter code: ABCD-EFGH
|
||||
Waiting for approval (polling every 1s)...
|
||||
"""
|
||||
let result = try #require(NousAuthFlow.parseDeviceCode(from: text))
|
||||
#expect(result.verificationURL.absoluteString == "https://portal.nousresearch.com/device/ABCD-EFGH")
|
||||
#expect(result.userCode == "ABCD-EFGH")
|
||||
}
|
||||
|
||||
@Test func ignoresNoiseBetweenExpectedLines() throws {
|
||||
// Hermes may log unrelated diagnostics between or after the two
|
||||
// expected lines. The parser anchors on line-start regex so noise
|
||||
// above, below, or even intermixed shouldn't block it.
|
||||
let text = """
|
||||
[DEBUG] some internal log line
|
||||
To continue:
|
||||
1. Open: https://portal.nousresearch.com/device/WXYZ-1234
|
||||
[DEBUG] another log line
|
||||
2. If prompted, enter code: WXYZ-1234
|
||||
extra trailing noise
|
||||
"""
|
||||
let result = try #require(NousAuthFlow.parseDeviceCode(from: text))
|
||||
#expect(result.userCode == "WXYZ-1234")
|
||||
}
|
||||
|
||||
@Test func returnsNilWhenUserCodeLineMissing() {
|
||||
let text = """
|
||||
To continue:
|
||||
1. Open: https://portal.nousresearch.com/device/AAAA-AAAA
|
||||
Waiting for approval...
|
||||
"""
|
||||
#expect(NousAuthFlow.parseDeviceCode(from: text) == nil)
|
||||
}
|
||||
|
||||
@Test func returnsNilWhenURLLineMissing() {
|
||||
let text = """
|
||||
To continue:
|
||||
2. If prompted, enter code: BBBB-BBBB
|
||||
"""
|
||||
#expect(NousAuthFlow.parseDeviceCode(from: text) == nil)
|
||||
}
|
||||
|
||||
@Test func returnsNilOnEmptyInput() {
|
||||
#expect(NousAuthFlow.parseDeviceCode(from: "") == nil)
|
||||
}
|
||||
|
||||
// MARK: - Subscription-required failure
|
||||
|
||||
@Test func parsesSubscriptionRequiredBillingURL() throws {
|
||||
let text = """
|
||||
Login successful!
|
||||
|
||||
Your Nous Portal account does not have an active subscription.
|
||||
Subscribe here: https://portal.nousresearch.com/billing
|
||||
|
||||
After subscribing, run `hermes model` again to finish setup.
|
||||
"""
|
||||
let url = try #require(NousAuthFlow.parseSubscriptionRequired(from: text))
|
||||
#expect(url.absoluteString == "https://portal.nousresearch.com/billing")
|
||||
}
|
||||
|
||||
@Test func subscriptionRequiredReturnsNilWithoutMarker() {
|
||||
let text = """
|
||||
hermes: something else went wrong
|
||||
Subscribe here: https://example.com/billing
|
||||
"""
|
||||
// The "Subscribe here:" URL alone isn't enough — we require the
|
||||
// specific subscription-required sentinel so we don't misclassify
|
||||
// unrelated errors as subscription failures.
|
||||
#expect(NousAuthFlow.parseSubscriptionRequired(from: text) == nil)
|
||||
}
|
||||
|
||||
@Test func subscriptionRequiredReturnsNilWhenBillingURLMissing() {
|
||||
let text = """
|
||||
Your Nous Portal account does not have an active subscription.
|
||||
(no subscribe here line)
|
||||
"""
|
||||
#expect(NousAuthFlow.parseSubscriptionRequired(from: text) == nil)
|
||||
}
|
||||
|
||||
// MARK: - State equality
|
||||
|
||||
@Test func stateEnumEquatableDistinguishesCases() {
|
||||
let u = URL(string: "https://example.com")!
|
||||
let a: NousAuthFlow.State = .waitingForApproval(userCode: "X", verificationURL: u)
|
||||
let b: NousAuthFlow.State = .waitingForApproval(userCode: "X", verificationURL: u)
|
||||
let c: NousAuthFlow.State = .waitingForApproval(userCode: "Y", verificationURL: u)
|
||||
#expect(a == b)
|
||||
#expect(a != c)
|
||||
#expect(NousAuthFlow.State.idle != NousAuthFlow.State.starting)
|
||||
#expect(NousAuthFlow.State.success != NousAuthFlow.State.failure(reason: "", billingURL: nil))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,261 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
@testable import scarf
|
||||
|
||||
/// Exercises the Scarf-managed AGENTS.md marker block logic added in
|
||||
/// v2.3. Tests operate on isolated temp directories — no dependency
|
||||
/// on ~/.hermes contents, no cross-suite lock needed.
|
||||
@Suite struct ProjectAgentContextServiceTests {
|
||||
|
||||
// MARK: - applyBlock pure-text transform
|
||||
|
||||
@Test func applyBlockPrependsWhenNoMarkersPresent() {
|
||||
let existing = "# My Template\n\nSome instructions.\n"
|
||||
let block = "<!-- scarf-project:begin -->\nhello\n<!-- scarf-project:end -->"
|
||||
let result = ProjectAgentContextService.applyBlock(block: block, to: existing)
|
||||
#expect(result.hasPrefix("<!-- scarf-project:begin -->"))
|
||||
#expect(result.contains("<!-- scarf-project:end -->"))
|
||||
#expect(result.contains("# My Template"))
|
||||
#expect(result.contains("Some instructions."))
|
||||
// Exactly one blank line between block and original content.
|
||||
#expect(result.contains("<!-- scarf-project:end -->\n\n# My Template"))
|
||||
}
|
||||
|
||||
@Test func applyBlockWritesFreshFileWhenEmpty() {
|
||||
let block = "<!-- scarf-project:begin -->\nhello\n<!-- scarf-project:end -->"
|
||||
let result = ProjectAgentContextService.applyBlock(block: block, to: "")
|
||||
// Empty input → just the block + trailing newline; no weird
|
||||
// leading whitespace.
|
||||
#expect(result == block + "\n")
|
||||
}
|
||||
|
||||
@Test func applyBlockReplacesExistingMarkerRegion() {
|
||||
let existing = """
|
||||
<!-- scarf-project:begin -->
|
||||
old content line 1
|
||||
old content line 2
|
||||
<!-- scarf-project:end -->
|
||||
|
||||
# Template docs preserved
|
||||
|
||||
Template behavior.
|
||||
"""
|
||||
let newBlock = "<!-- scarf-project:begin -->\nfresh content\n<!-- scarf-project:end -->"
|
||||
let result = ProjectAgentContextService.applyBlock(block: newBlock, to: existing)
|
||||
|
||||
#expect(result.contains("fresh content"))
|
||||
// Old content is gone.
|
||||
#expect(!result.contains("old content line 1"))
|
||||
#expect(!result.contains("old content line 2"))
|
||||
// Template content outside markers is preserved.
|
||||
#expect(result.contains("# Template docs preserved"))
|
||||
#expect(result.contains("Template behavior."))
|
||||
}
|
||||
|
||||
@Test func applyBlockIsIdempotent() {
|
||||
let existing = "# Project\n\nContent.\n"
|
||||
let block = "<!-- scarf-project:begin -->\nv1\n<!-- scarf-project:end -->"
|
||||
let once = ProjectAgentContextService.applyBlock(block: block, to: existing)
|
||||
let twice = ProjectAgentContextService.applyBlock(block: block, to: once)
|
||||
#expect(once == twice)
|
||||
}
|
||||
|
||||
@Test func applyBlockOrphanedBeginMarkerFallsBackToPrepend() {
|
||||
// Stray begin with no end: treat as "no well-formed block,"
|
||||
// prepend. Leaves the orphan in place — it was probably
|
||||
// hand-typed, not a corrupt Scarf write. Conservative.
|
||||
let existing = "<!-- scarf-project:begin -->\nstray text with no end marker\n"
|
||||
let block = "<!-- scarf-project:begin -->\nnew\n<!-- scarf-project:end -->"
|
||||
let result = ProjectAgentContextService.applyBlock(block: block, to: existing)
|
||||
#expect(result.hasPrefix("<!-- scarf-project:begin -->\nnew\n<!-- scarf-project:end -->"))
|
||||
#expect(result.contains("stray text with no end marker"))
|
||||
}
|
||||
|
||||
// MARK: - renderBlock content
|
||||
|
||||
@Test func renderBlockIncludesProjectIdentity() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let project = ProjectEntry(name: "My Project", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
let block = svc.renderBlock(for: project)
|
||||
|
||||
#expect(block.contains(ProjectAgentContextService.beginMarker))
|
||||
#expect(block.contains(ProjectAgentContextService.endMarker))
|
||||
#expect(block.contains("\"My Project\""))
|
||||
#expect(block.contains(dir))
|
||||
#expect(block.contains("dashboard.json"))
|
||||
}
|
||||
|
||||
@Test func renderBlockOmitsTemplateSectionForBareProject() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let project = ProjectEntry(name: "Bare", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
let block = svc.renderBlock(for: project)
|
||||
#expect(!block.contains("**Template:**"))
|
||||
#expect(block.contains("**Configuration fields:** (none)"))
|
||||
}
|
||||
|
||||
@Test func renderBlockIncludesTemplateWhenManifestPresent() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let scarfDir = dir + "/.scarf"
|
||||
try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true)
|
||||
// Minimal valid v1 manifest — no config schema.
|
||||
let manifest = """
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "author/example",
|
||||
"name": "Example",
|
||||
"version": "1.2.3",
|
||||
"description": "…",
|
||||
"contents": { "dashboard": true, "agentsMd": true }
|
||||
}
|
||||
"""
|
||||
try manifest.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/manifest.json"))
|
||||
|
||||
let project = ProjectEntry(name: "Example", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
let block = svc.renderBlock(for: project)
|
||||
#expect(block.contains("**Template:** `author/example` v1.2.3"))
|
||||
}
|
||||
|
||||
@Test func renderBlockListsConfigFieldNamesNotValues() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let scarfDir = dir + "/.scarf"
|
||||
try FileManager.default.createDirectory(atPath: scarfDir, withIntermediateDirectories: true)
|
||||
// Schema-bearing manifest with one string field and one secret.
|
||||
let manifest = """
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"id": "x/y",
|
||||
"name": "Y",
|
||||
"version": "1.0.0",
|
||||
"description": "…",
|
||||
"contents": { "dashboard": true, "agentsMd": true, "config": 2 },
|
||||
"config": {
|
||||
"schema": [
|
||||
{ "key": "site_url", "type": "string", "label": "Site URL", "required": true },
|
||||
{ "key": "api_token", "type": "secret", "label": "API Token", "required": true }
|
||||
]
|
||||
}
|
||||
}
|
||||
"""
|
||||
try manifest.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/manifest.json"))
|
||||
|
||||
// A config.json with a "secret" VALUE — the block must NOT
|
||||
// echo this value. If it does, secrets leak into an agent-
|
||||
// readable file, which is exactly the thing to avoid.
|
||||
let configJSON = """
|
||||
{
|
||||
"schemaVersion": 2,
|
||||
"templateId": "x/y",
|
||||
"values": {
|
||||
"site_url": { "type": "string", "value": "https://example.com" },
|
||||
"api_token": { "type": "keychainRef", "uri": "keychain://com.scarf.template.x-y/api_token:abc123" }
|
||||
},
|
||||
"updatedAt": "2026-04-24T00:00:00Z"
|
||||
}
|
||||
"""
|
||||
try configJSON.data(using: .utf8)!.write(to: URL(fileURLWithPath: scarfDir + "/config.json"))
|
||||
|
||||
let project = ProjectEntry(name: "Y", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
let block = svc.renderBlock(for: project)
|
||||
|
||||
// Field names present with type hints.
|
||||
#expect(block.contains("`site_url`"))
|
||||
#expect(block.contains("`api_token`"))
|
||||
#expect(block.contains("(secret — name only, value stored in Keychain)"))
|
||||
// CRITICAL: no VALUES appear — not the site URL, not the
|
||||
// keychain ref. The block is safe to drop into an agent
|
||||
// context.
|
||||
#expect(!block.contains("https://example.com"))
|
||||
#expect(!block.contains("keychain://"))
|
||||
#expect(!block.contains("abc123"))
|
||||
}
|
||||
|
||||
// MARK: - refresh end-to-end (temp dir on local filesystem)
|
||||
|
||||
@Test func refreshCreatesAGENTSMdWhenMissing() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let project = ProjectEntry(name: "Fresh", path: dir)
|
||||
|
||||
try ProjectAgentContextService(context: .local).refresh(for: project)
|
||||
|
||||
let agentsMd = dir + "/AGENTS.md"
|
||||
#expect(FileManager.default.fileExists(atPath: agentsMd))
|
||||
let contents = try String(contentsOf: URL(fileURLWithPath: agentsMd))
|
||||
#expect(contents.contains(ProjectAgentContextService.beginMarker))
|
||||
#expect(contents.contains(ProjectAgentContextService.endMarker))
|
||||
#expect(contents.contains("\"Fresh\""))
|
||||
}
|
||||
|
||||
@Test func refreshPreservesUserContentBelow() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let agentsMd = dir + "/AGENTS.md"
|
||||
let userContent = "# Template\n\nDo the thing.\n"
|
||||
try userContent.data(using: .utf8)!.write(to: URL(fileURLWithPath: agentsMd))
|
||||
|
||||
let project = ProjectEntry(name: "Preserved", path: dir)
|
||||
try ProjectAgentContextService(context: .local).refresh(for: project)
|
||||
|
||||
let after = try String(contentsOf: URL(fileURLWithPath: agentsMd))
|
||||
#expect(after.contains(ProjectAgentContextService.beginMarker))
|
||||
#expect(after.contains("# Template"))
|
||||
#expect(after.contains("Do the thing."))
|
||||
// Block goes FIRST; user content follows.
|
||||
let beginIdx = after.range(of: ProjectAgentContextService.beginMarker)!.lowerBound
|
||||
let userIdx = after.range(of: "# Template")!.lowerBound
|
||||
#expect(beginIdx < userIdx)
|
||||
}
|
||||
|
||||
@Test func refreshIsFullyIdempotent() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let project = ProjectEntry(name: "Twice", path: dir)
|
||||
let svc = ProjectAgentContextService(context: .local)
|
||||
try svc.refresh(for: project)
|
||||
let first = try Data(contentsOf: URL(fileURLWithPath: dir + "/AGENTS.md"))
|
||||
try svc.refresh(for: project)
|
||||
let second = try Data(contentsOf: URL(fileURLWithPath: dir + "/AGENTS.md"))
|
||||
#expect(first == second)
|
||||
}
|
||||
|
||||
@Test func refreshRewritesStaleBlock() throws {
|
||||
let dir = try Self.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: dir) }
|
||||
let agentsMd = dir + "/AGENTS.md"
|
||||
// Pre-seed a stale Scarf block with a different project name
|
||||
// and a user section below.
|
||||
let seed = """
|
||||
<!-- scarf-project:begin -->
|
||||
Old stale content — project was called "Something Else".
|
||||
<!-- scarf-project:end -->
|
||||
|
||||
# Template
|
||||
"""
|
||||
try seed.data(using: .utf8)!.write(to: URL(fileURLWithPath: agentsMd))
|
||||
|
||||
let project = ProjectEntry(name: "Current Name", path: dir)
|
||||
try ProjectAgentContextService(context: .local).refresh(for: project)
|
||||
|
||||
let after = try String(contentsOf: URL(fileURLWithPath: agentsMd))
|
||||
#expect(after.contains("\"Current Name\""))
|
||||
#expect(!after.contains("Something Else"))
|
||||
#expect(after.contains("# Template"))
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
nonisolated static func makeTempDir() throws -> String {
|
||||
let dir = NSTemporaryDirectory() + "scarf-project-context-test-" + UUID().uuidString
|
||||
try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
|
||||
return dir
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
@testable import scarf
|
||||
|
||||
/// v2.3 grew `ProjectEntry` with `folder` and `archived` fields.
|
||||
/// Both are optional/defaulted at the decoder so v2.2-era
|
||||
/// `~/.hermes/scarf/projects.json` files still parse cleanly, and
|
||||
/// v2.3-written files are forward-compatible with v2.2 readers
|
||||
/// (which ignore unknown keys). These tests lock in both ends of
|
||||
/// that contract.
|
||||
///
|
||||
/// No disk or Hermes dependency — we work entirely with in-memory
|
||||
/// `Data`, so the `TestRegistryLock` from `ProjectTemplateTests` isn't
|
||||
/// needed. Safe to run in parallel with every other test suite.
|
||||
@Suite struct ProjectRegistryMigrationTests {
|
||||
|
||||
@Test func decodesV22RegistryWithoutNewFields() throws {
|
||||
// v2.2-era file: just name + path. No folder, no archived.
|
||||
let json = """
|
||||
{
|
||||
"projects": [
|
||||
{ "name": "Legacy", "path": "/Users/x/legacy" },
|
||||
{ "name": "Another", "path": "/Users/x/another" }
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json)
|
||||
|
||||
#expect(registry.projects.count == 2)
|
||||
#expect(registry.projects[0].name == "Legacy")
|
||||
#expect(registry.projects[0].path == "/Users/x/legacy")
|
||||
// Defaults hydrate for absent v2.3 fields.
|
||||
#expect(registry.projects[0].folder == nil)
|
||||
#expect(registry.projects[0].archived == false)
|
||||
}
|
||||
|
||||
@Test func decodesV23RegistryWithFolderAndArchived() throws {
|
||||
let json = """
|
||||
{
|
||||
"projects": [
|
||||
{ "name": "Client A", "path": "/Users/x/a", "folder": "Clients" },
|
||||
{ "name": "Client B", "path": "/Users/x/b", "folder": "Clients", "archived": true },
|
||||
{ "name": "Personal", "path": "/Users/x/p" }
|
||||
]
|
||||
}
|
||||
""".data(using: .utf8)!
|
||||
|
||||
let registry = try JSONDecoder().decode(ProjectRegistry.self, from: json)
|
||||
|
||||
#expect(registry.projects.count == 3)
|
||||
#expect(registry.projects[0].folder == "Clients")
|
||||
#expect(registry.projects[0].archived == false)
|
||||
#expect(registry.projects[1].folder == "Clients")
|
||||
#expect(registry.projects[1].archived == true)
|
||||
#expect(registry.projects[2].folder == nil)
|
||||
#expect(registry.projects[2].archived == false)
|
||||
}
|
||||
|
||||
@Test func encodeOmitsDefaultedFields() throws {
|
||||
// A top-level, non-archived project should encode with ONLY
|
||||
// name + path keys. This keeps v2.3-written registries
|
||||
// loadable by v2.2 Scarf (which ignores unknown keys), and
|
||||
// keeps the file clean for the common case.
|
||||
let entry = ProjectEntry(name: "Plain", path: "/Users/x/plain")
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(entry)
|
||||
let s = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(s == #"{"name":"Plain","path":"\/Users\/x\/plain"}"#)
|
||||
}
|
||||
|
||||
@Test func encodeIncludesFolderWhenPresent() throws {
|
||||
let entry = ProjectEntry(name: "Acme", path: "/a", folder: "Clients")
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(entry)
|
||||
let s = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(s.contains(#""folder":"Clients""#))
|
||||
// archived still omitted when false — cleanliness matters.
|
||||
#expect(!s.contains(#""archived""#))
|
||||
}
|
||||
|
||||
@Test func encodeIncludesArchivedOnlyWhenTrue() throws {
|
||||
let archived = ProjectEntry(name: "Old", path: "/o", archived: true)
|
||||
let encoder = JSONEncoder()
|
||||
encoder.outputFormatting = [.sortedKeys]
|
||||
let data = try encoder.encode(archived)
|
||||
let s = try #require(String(data: data, encoding: .utf8))
|
||||
#expect(s.contains(#""archived":true"#))
|
||||
|
||||
let active = ProjectEntry(name: "New", path: "/n", archived: false)
|
||||
let data2 = try encoder.encode(active)
|
||||
let s2 = try #require(String(data: data2, encoding: .utf8))
|
||||
#expect(!s2.contains(#""archived""#))
|
||||
}
|
||||
|
||||
@Test func roundTripPreservesAllFields() throws {
|
||||
let original = ProjectRegistry(projects: [
|
||||
ProjectEntry(name: "Top", path: "/t"),
|
||||
ProjectEntry(name: "InFolder", path: "/f", folder: "Work"),
|
||||
ProjectEntry(name: "ArchivedTop", path: "/a", archived: true),
|
||||
ProjectEntry(name: "ArchivedInFolder", path: "/af", folder: "Work", archived: true)
|
||||
])
|
||||
|
||||
let encoded = try JSONEncoder().encode(original)
|
||||
let decoded = try JSONDecoder().decode(ProjectRegistry.self, from: encoded)
|
||||
|
||||
#expect(decoded.projects.count == 4)
|
||||
#expect(decoded.projects[0].folder == nil && decoded.projects[0].archived == false)
|
||||
#expect(decoded.projects[1].folder == "Work" && decoded.projects[1].archived == false)
|
||||
#expect(decoded.projects[2].folder == nil && decoded.projects[2].archived == true)
|
||||
#expect(decoded.projects[3].folder == "Work" && decoded.projects[3].archived == true)
|
||||
}
|
||||
|
||||
@Test func identityStaysKeyedOnName() throws {
|
||||
// ProjectEntry.id should remain `name`, so selecting by id
|
||||
// across a folder-move or archive-flip still works without
|
||||
// a reselection step.
|
||||
let a = ProjectEntry(name: "Foo", path: "/p")
|
||||
let b = ProjectEntry(name: "Foo", path: "/p", folder: "Clients")
|
||||
let c = ProjectEntry(name: "Foo", path: "/p", archived: true)
|
||||
#expect(a.id == "Foo")
|
||||
#expect(b.id == "Foo")
|
||||
#expect(c.id == "Foo")
|
||||
#expect(a.id == b.id)
|
||||
#expect(a.id == c.id)
|
||||
}
|
||||
}
|
||||
@@ -1064,6 +1064,68 @@ final class TestRegistryLock: @unchecked Sendable {
|
||||
#expect(cronPrompt.contains("{{PROJECT_DIR}}"))
|
||||
}
|
||||
|
||||
/// Exercises the second shipped template — `awizemann/template-author` —
|
||||
/// which is a skill-only bundle (no config, no cron, no memory). The
|
||||
/// shape is deliberately different from site-status-checker so a
|
||||
/// regression in the installer's "no config, no cron" path can't hide
|
||||
/// behind the richer example template. Also asserts the skill lands
|
||||
/// under the expected namespaced path so Hermes's recursive skill
|
||||
/// discovery finds it.
|
||||
@Test func templateAuthorParsesAndPlans() throws {
|
||||
let bundle = try Self.locateExample(author: "awizemann", name: "template-author")
|
||||
|
||||
let service = ProjectTemplateService(context: .local)
|
||||
let inspection = try service.inspect(zipPath: bundle)
|
||||
defer { service.cleanupTempDir(inspection.unpackedDir) }
|
||||
|
||||
// Manifest shape: schemaVersion 2 (contains `skills` claim, which
|
||||
// wasn't part of v1), no config, no cron, one skill.
|
||||
#expect(inspection.manifest.id == "awizemann/template-author")
|
||||
#expect(inspection.manifest.name == "Scarf Template Author")
|
||||
#expect(inspection.manifest.version == "1.0.0")
|
||||
#expect(inspection.manifest.schemaVersion == 2)
|
||||
#expect(inspection.manifest.contents.dashboard)
|
||||
#expect(inspection.manifest.contents.agentsMd)
|
||||
#expect(inspection.manifest.contents.cron == nil)
|
||||
#expect(inspection.manifest.contents.config == nil)
|
||||
#expect(inspection.manifest.contents.memory == nil)
|
||||
#expect(inspection.manifest.contents.skills == ["scarf-template-author"])
|
||||
#expect(inspection.manifest.config == nil)
|
||||
#expect(inspection.cronJobs.isEmpty)
|
||||
|
||||
// Plan: empty config, empty cron, but one skill queued for install
|
||||
// under the template's namespaced dir. The namespace path has to
|
||||
// match what the uninstaller wipes — `skills/templates/<slug>` —
|
||||
// or uninstall leaves orphan skill files.
|
||||
let scratch = try ProjectTemplateServiceTests.makeTempDir()
|
||||
defer { try? FileManager.default.removeItem(atPath: scratch) }
|
||||
let plan = try service.buildPlan(inspection: inspection, parentDir: scratch)
|
||||
#expect(plan.projectDir.hasSuffix("awizemann-template-author"))
|
||||
#expect(plan.cronJobs.isEmpty)
|
||||
#expect(plan.configSchema == nil)
|
||||
#expect(plan.configValues.isEmpty)
|
||||
#expect(plan.memoryAppendix == nil)
|
||||
|
||||
// The skill should land at
|
||||
// `~/.hermes/skills/templates/awizemann-template-author/scarf-template-author/SKILL.md`
|
||||
// — namespace dir + skill folder + SKILL.md. Anything else
|
||||
// breaks Hermes's recursive discovery or the uninstaller's
|
||||
// `rm -rf` on the namespace dir.
|
||||
let namespaceDir = try #require(plan.skillsNamespaceDir)
|
||||
#expect(namespaceDir.hasSuffix("/skills/templates/awizemann-template-author"))
|
||||
#expect(plan.skillsFiles.count == 1)
|
||||
let skillDest = try #require(plan.skillsFiles.first?.destinationPath)
|
||||
#expect(skillDest.hasSuffix("/scarf-template-author/SKILL.md"))
|
||||
#expect(skillDest.hasPrefix(namespaceDir))
|
||||
|
||||
// No-config templates deliberately skip the manifest cache —
|
||||
// the dashboard's Configuration button only shows up when
|
||||
// `.scarf/manifest.json` exists, so a skill-only template
|
||||
// like this one correctly doesn't surface that button.
|
||||
// (See ProjectTemplateService.buildPlan lines 198–227.)
|
||||
#expect(plan.manifestCachePath == nil)
|
||||
}
|
||||
|
||||
/// Resolve the example bundle path robustly. Unit-test working dirs
|
||||
/// differ between `xcodebuild test` (project root) and an Xcode IDE
|
||||
/// run (build-output dir), so we walk up from this source file until
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
@testable import scarf
|
||||
|
||||
/// Exercises the v2.3 registry verbs added to ProjectsViewModel:
|
||||
/// moveProject, renameProject, archiveProject, unarchiveProject,
|
||||
/// + the derived `folders` list. All verbs write through to
|
||||
/// `~/.hermes/scarf/projects.json` via ProjectDashboardService, so
|
||||
/// each test uses TestRegistryLock to snapshot + restore the real
|
||||
/// file. Cross-suite serialization ensures we don't race with other
|
||||
/// registry-touching tests.
|
||||
@MainActor @Suite(.serialized) struct ProjectsViewModelTests {
|
||||
|
||||
@Test func moveProjectSetsFolder() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "Alpha", path: "/a"),
|
||||
ProjectEntry(name: "Beta", path: "/b")
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
#expect(vm.projects.count == 2)
|
||||
|
||||
vm.moveProject(vm.projects[0], toFolder: "Clients")
|
||||
|
||||
#expect(vm.projects.count == 2)
|
||||
#expect(vm.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients")
|
||||
#expect(vm.projects.first(where: { $0.name == "Beta" })?.folder == nil)
|
||||
|
||||
// Round-trip: reload from disk and confirm the move persisted.
|
||||
let fresh = ProjectDashboardService(context: .local).loadRegistry()
|
||||
#expect(fresh.projects.first(where: { $0.name == "Alpha" })?.folder == "Clients")
|
||||
}
|
||||
|
||||
@Test func moveProjectToNilReturnsToTopLevel() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "Nested", path: "/n", folder: "Clients")
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
vm.moveProject(vm.projects[0], toFolder: nil)
|
||||
|
||||
#expect(vm.projects[0].folder == nil)
|
||||
let fresh = ProjectDashboardService(context: .local).loadRegistry()
|
||||
#expect(fresh.projects[0].folder == nil)
|
||||
}
|
||||
|
||||
@Test func renameProjectUpdatesNameAndPreservesOtherFields() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "OldName", path: "/p", folder: "Work", archived: false)
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
vm.selectProject(vm.projects[0])
|
||||
|
||||
let ok = vm.renameProject(vm.projects[0], to: "NewName")
|
||||
#expect(ok == true)
|
||||
#expect(vm.projects.count == 1)
|
||||
#expect(vm.projects[0].name == "NewName")
|
||||
#expect(vm.projects[0].folder == "Work")
|
||||
#expect(vm.projects[0].archived == false)
|
||||
// Selection follows the rename — the user stays on the same
|
||||
// project they were on.
|
||||
#expect(vm.selectedProject?.name == "NewName")
|
||||
}
|
||||
|
||||
@Test func renameProjectRejectsDuplicateName() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "A", path: "/a"),
|
||||
ProjectEntry(name: "B", path: "/b")
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
|
||||
// Renaming A to B should be refused — B already exists.
|
||||
let ok = vm.renameProject(vm.projects[0], to: "B")
|
||||
#expect(ok == false)
|
||||
// Registry unchanged.
|
||||
#expect(vm.projects.map(\.name) == ["A", "B"])
|
||||
}
|
||||
|
||||
@Test func renameProjectRejectsEmptyName() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "Foo", path: "/f")
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
|
||||
#expect(vm.renameProject(vm.projects[0], to: "") == false)
|
||||
#expect(vm.renameProject(vm.projects[0], to: " ") == false)
|
||||
#expect(vm.projects[0].name == "Foo")
|
||||
}
|
||||
|
||||
@Test func renameProjectToSameNameIsNoOpSuccess() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "Foo", path: "/f")
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
|
||||
#expect(vm.renameProject(vm.projects[0], to: "Foo") == true)
|
||||
// Whitespace around matching name also no-ops.
|
||||
#expect(vm.renameProject(vm.projects[0], to: " Foo ") == true)
|
||||
#expect(vm.projects[0].name == "Foo")
|
||||
}
|
||||
|
||||
@Test func archiveAndUnarchiveProject() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "Target", path: "/t")
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
vm.selectProject(vm.projects[0])
|
||||
#expect(vm.projects[0].archived == false)
|
||||
#expect(vm.selectedProject != nil)
|
||||
|
||||
vm.archiveProject(vm.projects[0])
|
||||
#expect(vm.projects[0].archived == true)
|
||||
// Archiving clears the selection so the dashboard doesn't
|
||||
// linger on a project the sidebar will hide.
|
||||
#expect(vm.selectedProject == nil)
|
||||
|
||||
vm.unarchiveProject(vm.projects[0])
|
||||
#expect(vm.projects[0].archived == false)
|
||||
// Unarchive doesn't re-select — the user chose to hide it,
|
||||
// surfacing it doesn't mean they want focus back.
|
||||
#expect(vm.selectedProject == nil)
|
||||
}
|
||||
|
||||
@Test func foldersListIsSortedAndDeduped() async throws {
|
||||
let snapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
defer { TestRegistryLock.restore(snapshot) }
|
||||
try seedRegistry(.init(projects: [
|
||||
ProjectEntry(name: "A", path: "/a", folder: "Work"),
|
||||
ProjectEntry(name: "B", path: "/b", folder: "Personal"),
|
||||
ProjectEntry(name: "C", path: "/c", folder: "Work"),
|
||||
ProjectEntry(name: "D", path: "/d"), // top-level
|
||||
ProjectEntry(name: "E", path: "/e", folder: "") // empty string treated as nil
|
||||
]))
|
||||
|
||||
let vm = ProjectsViewModel(context: .local)
|
||||
vm.load()
|
||||
|
||||
#expect(vm.folders == ["Personal", "Work"])
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
@MainActor
|
||||
private func seedRegistry(_ registry: ProjectRegistry) throws {
|
||||
try ProjectDashboardService(context: .local).saveRegistry(registry)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
@testable import scarf
|
||||
|
||||
/// Exercises the v2.3 sidecar at `~/.hermes/scarf/session_project_map.json`
|
||||
/// via the real `ServerContext.local`. Each test snapshots + restores
|
||||
/// the file through `TestRegistryLock` (reused — the sidecar lives
|
||||
/// in the same scarf/ dir as projects.json, so serialising on one
|
||||
/// lock prevents both cross-suite races).
|
||||
///
|
||||
/// We scope the shared lock to this file's registry helper so tests
|
||||
/// here don't step on the real registry either.
|
||||
@Suite(.serialized) struct SessionAttributionServiceTests {
|
||||
|
||||
@Test func loadOnMissingFileReturnsEmptyMap() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
Self.deleteSidecar()
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
let map = svc.load()
|
||||
#expect(map.mappings.isEmpty)
|
||||
#expect(svc.projectPath(for: "anything") == nil)
|
||||
#expect(svc.sessionIDs(forProject: "/anything").isEmpty)
|
||||
}
|
||||
|
||||
@Test func attributeWritesMappingAndPersists() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
Self.deleteSidecar()
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
svc.attribute(sessionID: "sess-1", toProjectPath: "/proj/a")
|
||||
|
||||
// Read back via a fresh service instance — confirms the
|
||||
// write actually landed on disk, not just the in-memory map.
|
||||
let fresh = SessionAttributionService(context: .local)
|
||||
#expect(fresh.projectPath(for: "sess-1") == "/proj/a")
|
||||
|
||||
// updatedAt populated on write.
|
||||
let map = fresh.load()
|
||||
let ts = try #require(map.updatedAt)
|
||||
#expect(!ts.isEmpty)
|
||||
}
|
||||
|
||||
@Test func attributeIsIdempotent() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
Self.deleteSidecar()
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
svc.attribute(sessionID: "s", toProjectPath: "/p")
|
||||
let firstStamp = svc.load().updatedAt
|
||||
// Call again with the same pair — should short-circuit, NOT
|
||||
// bump updatedAt. We check that the timestamp didn't change
|
||||
// even if the file would have been rewritten.
|
||||
svc.attribute(sessionID: "s", toProjectPath: "/p")
|
||||
let secondStamp = svc.load().updatedAt
|
||||
#expect(firstStamp == secondStamp)
|
||||
}
|
||||
|
||||
@Test func reattributeChangesMapping() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
Self.deleteSidecar()
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
svc.attribute(sessionID: "s", toProjectPath: "/a")
|
||||
svc.attribute(sessionID: "s", toProjectPath: "/b")
|
||||
#expect(svc.projectPath(for: "s") == "/b")
|
||||
#expect(svc.sessionIDs(forProject: "/a").isEmpty)
|
||||
#expect(svc.sessionIDs(forProject: "/b") == ["s"])
|
||||
}
|
||||
|
||||
@Test func reverseLookupReturnsAllAttributedSessions() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
Self.deleteSidecar()
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
svc.attribute(sessionID: "s1", toProjectPath: "/proj")
|
||||
svc.attribute(sessionID: "s2", toProjectPath: "/proj")
|
||||
svc.attribute(sessionID: "s3", toProjectPath: "/other")
|
||||
|
||||
#expect(svc.sessionIDs(forProject: "/proj") == ["s1", "s2"])
|
||||
#expect(svc.sessionIDs(forProject: "/other") == ["s3"])
|
||||
#expect(svc.sessionIDs(forProject: "/nobody").isEmpty)
|
||||
}
|
||||
|
||||
@Test func forgetRemovesMapping() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
Self.deleteSidecar()
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
svc.attribute(sessionID: "s", toProjectPath: "/p")
|
||||
#expect(svc.projectPath(for: "s") == "/p")
|
||||
|
||||
svc.forget(sessionID: "s")
|
||||
#expect(svc.projectPath(for: "s") == nil)
|
||||
// Forget on a missing session is a no-op, not an error.
|
||||
svc.forget(sessionID: "s")
|
||||
#expect(svc.projectPath(for: "s") == nil)
|
||||
}
|
||||
|
||||
@Test func corruptedFileReturnsEmptyMap() throws {
|
||||
let snapshot = Self.snapshot()
|
||||
defer { Self.restore(snapshot) }
|
||||
// Write garbage to the sidecar path and confirm the service
|
||||
// treats it as "no attributions" rather than crashing. Users
|
||||
// hand-editing the JSON shouldn't soft-brick the Sessions tab.
|
||||
let path = ServerContext.local.paths.sessionProjectMap
|
||||
try FileManager.default.createDirectory(
|
||||
atPath: (path as NSString).deletingLastPathComponent,
|
||||
withIntermediateDirectories: true
|
||||
)
|
||||
try "not json at all".data(using: .utf8)!.write(to: URL(fileURLWithPath: path))
|
||||
|
||||
let svc = SessionAttributionService(context: .local)
|
||||
let map = svc.load()
|
||||
#expect(map.mappings.isEmpty)
|
||||
}
|
||||
|
||||
// MARK: - Helpers
|
||||
|
||||
/// Snapshot + restore the sidecar file (and delete if missing).
|
||||
/// Uses the shared TestRegistryLock so this suite serialises
|
||||
/// with any other registry-writing suite — both touch scarfDir.
|
||||
static func snapshot() -> (lockToken: Any, data: Data?) {
|
||||
// Re-use the ProjectTemplateTests lock implementation —
|
||||
// same NSLock gates all scarfDir writes across suites.
|
||||
let projectSnapshot = TestRegistryLock.acquireAndSnapshot()
|
||||
let path = ServerContext.local.paths.sessionProjectMap
|
||||
let sidecarData = try? Data(contentsOf: URL(fileURLWithPath: path))
|
||||
return (lockToken: projectSnapshot as Any, data: sidecarData)
|
||||
}
|
||||
|
||||
static func restore(_ snapshot: (lockToken: Any, data: Data?)) {
|
||||
let path = ServerContext.local.paths.sessionProjectMap
|
||||
if let data = snapshot.data {
|
||||
try? data.write(to: URL(fileURLWithPath: path))
|
||||
} else {
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
// Release the shared lock via the existing helper.
|
||||
TestRegistryLock.restore(snapshot.lockToken as? Data)
|
||||
}
|
||||
|
||||
static func deleteSidecar() {
|
||||
let path = ServerContext.local.paths.sessionProjectMap
|
||||
try? FileManager.default.removeItem(atPath: path)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,200 @@
|
||||
import Testing
|
||||
import Foundation
|
||||
import ScarfCore
|
||||
@testable import scarf
|
||||
|
||||
/// Invariants around Hermes v0.10.0 Tool Gateway integration:
|
||||
/// overlay-provider merge, Nous Portal subscription detection, and
|
||||
/// `platform_toolsets` YAML parsing.
|
||||
@Suite struct ToolGatewayTests {
|
||||
|
||||
// MARK: - Fixtures
|
||||
|
||||
/// Minimal models.dev cache with exactly two providers so the overlay
|
||||
/// merge is easy to reason about — none of them are overlays.
|
||||
private func writeCacheFixture() throws -> String {
|
||||
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-catalog-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let path = dir.appendingPathComponent("models_dev_cache.json").path
|
||||
let json = """
|
||||
{
|
||||
"anthropic": {
|
||||
"name": "Anthropic",
|
||||
"models": {
|
||||
"claude-sonnet-4-5-20250929": { "name": "Claude Sonnet 4.5" }
|
||||
}
|
||||
},
|
||||
"openai": {
|
||||
"name": "OpenAI",
|
||||
"models": {
|
||||
"gpt-4o": { "name": "GPT-4o" }
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
try json.write(toFile: path, atomically: true, encoding: .utf8)
|
||||
return path
|
||||
}
|
||||
|
||||
private func writeAuthFixture(_ body: String) throws -> String {
|
||||
let dir = FileManager.default.temporaryDirectory.appendingPathComponent("scarf-auth-\(UUID().uuidString)")
|
||||
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
|
||||
let path = dir.appendingPathComponent("auth.json").path
|
||||
try body.write(toFile: path, atomically: true, encoding: .utf8)
|
||||
return path
|
||||
}
|
||||
|
||||
// MARK: - ModelCatalogService overlay merge
|
||||
|
||||
@Test func overlayOnlyProvidersAppearInPicker() throws {
|
||||
let path = try writeCacheFixture()
|
||||
let service = ModelCatalogService(path: path)
|
||||
let providers = service.loadProviders()
|
||||
|
||||
let ids = providers.map(\.providerID)
|
||||
#expect(ids.contains("nous"), "Nous Portal must appear after overlay merge")
|
||||
#expect(ids.contains("openai-codex"), "OpenAI Codex overlay must appear")
|
||||
#expect(ids.contains("qwen-oauth"), "Qwen OAuth overlay must appear")
|
||||
// Cached providers still present.
|
||||
#expect(ids.contains("anthropic"))
|
||||
#expect(ids.contains("openai"))
|
||||
}
|
||||
|
||||
@Test func nousPortalSortsFirst() throws {
|
||||
let path = try writeCacheFixture()
|
||||
let service = ModelCatalogService(path: path)
|
||||
let providers = service.loadProviders()
|
||||
#expect(providers.first?.providerID == "nous",
|
||||
"Subscription-gated providers must sort before the alphabetical block")
|
||||
}
|
||||
|
||||
@Test func overlayProvidersCarryMetadata() throws {
|
||||
let path = try writeCacheFixture()
|
||||
let service = ModelCatalogService(path: path)
|
||||
let providers = service.loadProviders()
|
||||
|
||||
let nous = providers.first { $0.providerID == "nous" }
|
||||
#expect(nous?.isOverlay == true)
|
||||
#expect(nous?.subscriptionGated == true)
|
||||
#expect(nous?.providerName == "Nous Portal")
|
||||
#expect(nous?.modelCount == 0, "Overlay-only providers have no models in the cache")
|
||||
|
||||
let codex = providers.first { $0.providerID == "openai-codex" }
|
||||
#expect(codex?.isOverlay == true)
|
||||
#expect(codex?.subscriptionGated == false,
|
||||
"Only Nous is subscription-gated today")
|
||||
}
|
||||
|
||||
@Test func cachedProvidersAreNotMarkedOverlay() throws {
|
||||
let path = try writeCacheFixture()
|
||||
let service = ModelCatalogService(path: path)
|
||||
let providers = service.loadProviders()
|
||||
|
||||
let anthropic = providers.first { $0.providerID == "anthropic" }
|
||||
#expect(anthropic?.isOverlay == false)
|
||||
#expect(anthropic?.subscriptionGated == false)
|
||||
}
|
||||
|
||||
@Test func providerByIDReturnsOverlayWhenCacheMisses() throws {
|
||||
let path = try writeCacheFixture()
|
||||
let service = ModelCatalogService(path: path)
|
||||
|
||||
let nous = service.providerByID("nous")
|
||||
#expect(nous?.providerName == "Nous Portal")
|
||||
#expect(nous?.isOverlay == true)
|
||||
|
||||
let missing = service.providerByID("definitely-not-a-provider")
|
||||
#expect(missing == nil)
|
||||
}
|
||||
|
||||
// MARK: - NousSubscriptionService
|
||||
|
||||
@Test func subscriptionAbsentWhenAuthFileMissing() throws {
|
||||
let path = "/tmp/this-file-should-not-exist-\(UUID().uuidString).json"
|
||||
let service = NousSubscriptionService(path: path)
|
||||
let state = service.loadState()
|
||||
#expect(state == .absent)
|
||||
}
|
||||
|
||||
@Test func subscriptionAbsentWhenProvidersEmpty() throws {
|
||||
let path = try writeAuthFixture("""
|
||||
{ "version": 1, "providers": {}, "active_provider": null }
|
||||
""")
|
||||
let state = NousSubscriptionService(path: path).loadState()
|
||||
#expect(state.present == false)
|
||||
#expect(state.subscribed == false)
|
||||
}
|
||||
|
||||
@Test func subscriptionPresentButInactiveWhenOtherProviderActive() throws {
|
||||
let path = try writeAuthFixture("""
|
||||
{
|
||||
"version": 1,
|
||||
"providers": { "nous": { "access_token": "tok-12345" } },
|
||||
"active_provider": "anthropic"
|
||||
}
|
||||
""")
|
||||
let state = NousSubscriptionService(path: path).loadState()
|
||||
#expect(state.present == true)
|
||||
#expect(state.providerIsNous == false)
|
||||
#expect(state.subscribed == false,
|
||||
"Auth alone isn't enough — the Tool Gateway only routes when Nous is the active provider")
|
||||
}
|
||||
|
||||
@Test func subscriptionActiveWhenAuthAndActiveProviderLineUp() throws {
|
||||
let path = try writeAuthFixture("""
|
||||
{
|
||||
"version": 1,
|
||||
"providers": { "nous": { "access_token": "tok-12345" } },
|
||||
"active_provider": "nous"
|
||||
}
|
||||
""")
|
||||
let state = NousSubscriptionService(path: path).loadState()
|
||||
#expect(state.present == true)
|
||||
#expect(state.providerIsNous == true)
|
||||
#expect(state.subscribed == true)
|
||||
}
|
||||
|
||||
@Test func subscriptionAbsentWhenTokenEmpty() throws {
|
||||
let path = try writeAuthFixture("""
|
||||
{
|
||||
"version": 1,
|
||||
"providers": { "nous": { "access_token": "" } },
|
||||
"active_provider": "nous"
|
||||
}
|
||||
""")
|
||||
let state = NousSubscriptionService(path: path).loadState()
|
||||
#expect(state.present == false,
|
||||
"Empty token is as good as no token — don't claim subscription")
|
||||
}
|
||||
|
||||
@Test func subscriptionAbsentOnMalformedJSON() throws {
|
||||
let path = try writeAuthFixture("{ this is not valid json")
|
||||
let state = NousSubscriptionService(path: path).loadState()
|
||||
#expect(state == .absent)
|
||||
}
|
||||
|
||||
// MARK: - platform_toolsets YAML parse
|
||||
|
||||
@Test func platformToolsetsParsed() throws {
|
||||
let yaml = """
|
||||
model:
|
||||
default: claude-sonnet-4.5
|
||||
provider: anthropic
|
||||
platform_toolsets:
|
||||
cli:
|
||||
- browser
|
||||
- messaging
|
||||
slack:
|
||||
- messaging
|
||||
"""
|
||||
let parsed = HermesFileService.parseNestedYAML(yaml)
|
||||
#expect(parsed.lists["platform_toolsets.cli"] == ["browser", "messaging"])
|
||||
#expect(parsed.lists["platform_toolsets.slack"] == ["messaging"])
|
||||
}
|
||||
|
||||
@Test func platformToolsetsEmptyWhenMissing() throws {
|
||||
// HermesConfig.empty should have no platform toolsets.
|
||||
let config = HermesConfig.empty
|
||||
#expect(config.platformToolsets.isEmpty)
|
||||
}
|
||||
}
|
||||