diff --git a/GraphcodeKit/Sources/Domain/NodModelCatalog.swift b/GraphcodeKit/Sources/Domain/NodModelCatalog.swift new file mode 100644 index 00000000..8494e871 --- /dev/null +++ b/GraphcodeKit/Sources/Domain/NodModelCatalog.swift @@ -0,0 +1,163 @@ +import Foundation + +/// The models Nod offers on each engine, and which one a loop gets when nobody picked. +/// +/// Settings › Agents › Nod and the new-loop agent menu both read this, and so does the +/// launch path, so a loop never starts on a model the menu would not have offered. A +/// stored choice that is no longer in the list (the lineup moved on, or the engine was +/// switched) falls back to the engine's default for that loop type rather than being +/// passed through to fail at launch. +public struct NodModel: Equatable, Hashable, Sendable, Identifiable { + public enum Family: String, Sendable { + case claude + case gpt + case gemini + } + + /// The id the engine takes: an alias on the Claude Agent SDK, which keeps resolving to + /// the current model in its class, and a versioned id on the Copilot SDK. + public var id: String + public var displayName: String + public var family: Family + public var tier: ModelTier + + public init(id: String, displayName: String, family: Family, tier: ModelTier) { + self.id = id + self.displayName = displayName + self.family = family + self.tier = tier + } +} + +public enum NodModelCatalog { + public static func models(for engine: NodEngine) -> [NodModel] { + switch engine { + case .claudeAgentSDK: + return [ + NodModel(id: "opus", displayName: "Opus", family: .claude, tier: .capable), + NodModel(id: "sonnet", displayName: "Sonnet", family: .claude, tier: .standard), + NodModel(id: "haiku", displayName: "Haiku", family: .claude, tier: .fast), + ] + case .copilotSDK: + // Read off `copilot help config` at 1.0.84, the list the Copilot SDK shares. + return [ + NodModel(id: "gpt-6-sol", displayName: "GPT-6 Sol", family: .gpt, tier: .standard), + NodModel( + id: "gpt-5.6-luna", displayName: "GPT-5.6 Luna", family: .gpt, tier: .fast), + NodModel( + id: "claude-opus-5.5", displayName: "Claude Opus 5.5", family: .claude, + tier: .capable), + NodModel( + id: "claude-sonnet-5", displayName: "Claude Sonnet 5", family: .claude, + tier: .standard), + NodModel( + id: "gemini-3.8-flash", displayName: "Gemini 3.8 Flash", family: .gemini, + tier: .fast), + ] + } + } + + /// "Opus · Sonnet · Haiku" or "GPT · Claude · Gemini", the line under each engine card. + public static func familySummary(for engine: NodEngine) -> String { + switch engine { + case .claudeAgentSDK: + return models(for: engine).map(\.displayName).joined(separator: " · ") + case .copilotSDK: + return "GPT · Claude · Gemini" + } + } + + public static func model(id: String, engine: NodEngine) -> NodModel? { + models(for: engine).first { $0.id == id } + } + + /// The first model of `tier` on `engine`; every engine offers all three tiers. + public static func model(for tier: ModelTier, engine: NodEngine) -> NodModel { + models(for: engine).first { $0.tier == tier } ?? models(for: engine)[0] + } + + /// What a loop type runs on when Settings has no choice for it: the design's Main + /// Sonnet, Goal Opus, Timed Haiku, Turn Sonnet, Composite Opus. + public static func defaultTier(for loopType: LoopType) -> ModelTier { + switch loopType { + case .sketch, .turnBased: return .standard + case .goalBased, .composite: return .capable + case .timeBased: return .fast + } + } + + public static let defaultCompositeChildTier = ModelTier.standard + public static let defaultEvaluatorTier = ModelTier.fast +} + +extension NodSettings { + /// The model a new `loopType` loop starts on. An explicit `tier` (the new-loop menu's + /// pick) wins; otherwise the per-type setting, if it still names a model this engine + /// offers; otherwise the type's default. + public func resolvedModel(for loopType: LoopType, tier: ModelTier? = nil) -> NodModel { + if let tier { return NodModelCatalog.model(for: tier, engine: engine) } + return stored(model(for: loopType)) + ?? NodModelCatalog.model(for: NodModelCatalog.defaultTier(for: loopType), engine: engine) + } + + public var resolvedCompositeChildModel: NodModel { + stored(compositeChildModel) + ?? NodModelCatalog.model(for: NodModelCatalog.defaultCompositeChildTier, engine: engine) + } + + public var resolvedGoalEvaluatorModel: NodModel { + stored(goalEvaluatorModel) + ?? NodModelCatalog.model(for: NodModelCatalog.defaultEvaluatorTier, engine: engine) + } + + /// Sets the per-type model; choosing the type's default clears the entry, so a later + /// change to the defaults reaches it. + public mutating func setModel(_ model: NodModel, for loopType: LoopType) { + let isDefault = + model + == NodModelCatalog.model(for: NodModelCatalog.defaultTier(for: loopType), engine: engine) + modelsByLoopType[loopType.rawValue] = isDefault ? nil : model.id + } + + /// Switching engines drops model choices the new engine cannot run. Existing loops keep + /// the engine they started on; this only changes what new loops get. + public mutating func switchEngine(to newEngine: NodEngine) { + guard newEngine != engine else { return } + engine = newEngine + modelsByLoopType = modelsByLoopType.filter { + NodModelCatalog.model(id: $0.value, engine: newEngine) != nil + } + if let child = compositeChildModel, NodModelCatalog.model(id: child, engine: newEngine) == nil { + compositeChildModel = nil + } + if let evaluator = goalEvaluatorModel, + NodModelCatalog.model(id: evaluator, engine: newEngine) == nil + { + goalEvaluatorModel = nil + } + } + + /// Adds a shell pattern, trimmed, ignoring blanks and duplicates. Returns whether the + /// list changed. + @discardableResult + public mutating func addAllowlistPattern(_ pattern: String) -> Bool { + let trimmed = pattern.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !shellAllowlist.contains(trimmed) else { return false } + shellAllowlist.append(trimmed) + return true + } + + public mutating func removeAllowlistPattern(_ pattern: String) { + shellAllowlist.removeAll { $0 == pattern } + } + + /// A negative or non-finite cap is stored as no cap rather than as a value the runtime + /// would have to second-guess. + public mutating func setSpendCap(_ dollars: Double) { + spendCapUSD = dollars.isFinite && dollars > 0 ? (dollars * 100).rounded() / 100 : 0 + } + + private func stored(_ id: String?) -> NodModel? { + id.flatMap { NodModelCatalog.model(id: $0, engine: engine) } + } +} diff --git a/GraphcodeKit/Sources/Domain/NodSettings.swift b/GraphcodeKit/Sources/Domain/NodSettings.swift index cb16175d..e0361760 100644 --- a/GraphcodeKit/Sources/Domain/NodSettings.swift +++ b/GraphcodeKit/Sources/Domain/NodSettings.swift @@ -40,6 +40,9 @@ public struct NodSettings: Codable, Equatable, Sendable { /// Per-run cap for unattended loops (timed and composite children), in dollars; 0 is no /// cap. Copilot reports premium requests instead and is capped by its plan. public var spendCapUSD: Double + /// MCP servers from the project's `.mcp.json` switched off for Nod, by name. The + /// built-in graphcode server is always on and never listed here. + public var disabledMCPServers: [String] public init( engine: NodEngine = .claudeAgentSDK, @@ -52,7 +55,8 @@ public struct NodSettings: Codable, Equatable, Sendable { editsOutsideWorktree: Ask = .never, messagesOtherLoops: MessagePolicy = .draftForMe, shellAllowlist: [String] = [], - spendCapUSD: Double = 2 + spendCapUSD: Double = 2, + disabledMCPServers: [String] = [] ) { self.engine = engine self.modelsByLoopType = modelsByLoopType @@ -65,6 +69,7 @@ public struct NodSettings: Codable, Equatable, Sendable { self.messagesOtherLoops = messagesOtherLoops self.shellAllowlist = shellAllowlist self.spendCapUSD = spendCapUSD + self.disabledMCPServers = disabledMCPServers } public init(from decoder: Decoder) throws { @@ -84,6 +89,7 @@ public struct NodSettings: Codable, Equatable, Sendable { messagesOtherLoops = try value(.messagesOtherLoops, defaults.messagesOtherLoops) shellAllowlist = try value(.shellAllowlist, defaults.shellAllowlist) spendCapUSD = try value(.spendCapUSD, defaults.spendCapUSD) + disabledMCPServers = try value(.disabledMCPServers, defaults.disabledMCPServers) } public func model(for loopType: LoopType) -> String? { diff --git a/graphcode/Sources/Clients/CopilotDeviceFlow.swift b/graphcode/Sources/Clients/CopilotDeviceFlow.swift new file mode 100644 index 00000000..fd26d5aa --- /dev/null +++ b/graphcode/Sources/Clients/CopilotDeviceFlow.swift @@ -0,0 +1,195 @@ +import Foundation + +/// GitHub's OAuth device flow, for Nod's Copilot engine: ask for a code, show it, poll +/// until the person enters it at github.com/login/device, then read the account's +/// Copilot plan so the success state can say what the seat buys. +/// +/// The token goes to the Keychain (`NodCredential.githubCopilot`). Every request goes +/// through `transport`, so tests replay GitHub's documented responses without a network. +struct CopilotDeviceFlow: Sendable { + typealias Transport = @Sendable (URLRequest) async throws -> (Data, Int) + + struct DeviceCode: Equatable, Sendable { + var deviceCode: String + var userCode: String + var verificationURL: URL + var expiresAt: Date + var interval: Duration + } + + struct Account: Equatable, Sendable { + var login: String + /// GitHub's plan word, e.g. `business`; `planName` is the display form. + var plan: String? + var modelCount: Int? + var premiumRequestsUsed: Int? + var premiumRequestsLimit: Int? + + var planName: String? { + plan.map { "Copilot " + $0.replacingOccurrences(of: "_", with: " ").capitalized } + } + } + + enum Failure: Error, Equatable { + /// This build carries no GitHub OAuth app client id. + case notConfigured + case expired + case denied + case unexpected(String) + } + + var clientID: String? + var transport: Transport + var sleep: @Sendable (Duration) async throws -> Void = { try await Task.sleep(for: $0) } + var now: @Sendable () -> Date = { Date() } + + static let scope = "read:user" + + static var bundledClientID: String? { + (Bundle.main.object(forInfoDictionaryKey: "GraphCodeGitHubClientID") as? String) + .flatMap { $0.isEmpty ? nil : $0 } + } + + static let live = CopilotDeviceFlow(clientID: bundledClientID) { request in + let (data, response) = try await URLSession.shared.data(for: request) + return (data, (response as? HTTPURLResponse)?.statusCode ?? 0) + } + + func requestCode() async throws -> DeviceCode { + guard let clientID else { throw Failure.notConfigured } + let json = try await post( + "https://github.com/login/device/code", ["client_id": clientID, "scope": Self.scope]) + guard + let deviceCode = json["device_code"] as? String, + let userCode = json["user_code"] as? String, + let uri = (json["verification_uri"] as? String).flatMap(URL.init(string:)), + let expiresIn = json["expires_in"] as? Int + else { throw Failure.unexpected(Self.describe(json)) } + return DeviceCode( + deviceCode: deviceCode, userCode: userCode, verificationURL: uri, + expiresAt: now().addingTimeInterval(TimeInterval(expiresIn)), + interval: .seconds(json["interval"] as? Int ?? 5)) + } + + /// Polls until GitHub hands over a token, the code expires, or the person declines. + /// `slow_down` adds five seconds to the interval, as GitHub asks. + func pollForToken(_ code: DeviceCode) async throws -> String { + guard let clientID else { throw Failure.notConfigured } + var interval = code.interval + while true { + try await sleep(interval) + guard now() < code.expiresAt else { throw Failure.expired } + let json = try await post( + "https://github.com/login/oauth/access_token", + [ + "client_id": clientID, "device_code": code.deviceCode, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + ]) + if let token = json["access_token"] as? String { return token } + switch json["error"] as? String { + case "authorization_pending": + continue + case "slow_down": + interval = .seconds(json["interval"] as? Int ?? Int(interval.components.seconds) + 5) + case "expired_token": + throw Failure.expired + case "access_denied": + throw Failure.denied + default: + throw Failure.unexpected(Self.describe(json)) + } + } + } + + /// The login, then the Copilot plan, premium requests and model count. Only the login + /// is required: the plan endpoints are GitHub's internal ones and a missing answer + /// leaves that line off the success state rather than failing a sign-in that worked. + func account(token: String) async throws -> Account { + let user = try await get("https://api.github.com/user", token: "token \(token)") + guard let login = user["login"] as? String else { + throw Failure.unexpected(Self.describe(user)) + } + var account = Account(login: login) + if let copilot = try? await get( + "https://api.github.com/copilot_internal/user", token: "token \(token)") + { + account.plan = copilot["copilot_plan"] as? String + if let premium = (copilot["quota_snapshots"] as? [String: Any])?["premium_interactions"] + as? [String: Any], + premium["unlimited"] as? Bool != true, + let entitlement = premium["entitlement"] as? Int, + let remaining = premium["remaining"] as? Int + { + account.premiumRequestsLimit = entitlement + account.premiumRequestsUsed = max(0, entitlement - remaining) + } + } + if let session = try? await get( + "https://api.github.com/copilot_internal/v2/token", token: "token \(token)"), + let sessionToken = session["token"] as? String, + let models = try? await get( + "https://api.githubcopilot.com/models", token: "Bearer \(sessionToken)"), + let data = models["data"] as? [[String: Any]] + { + account.modelCount = data.filter { $0["model_picker_enabled"] as? Bool ?? true }.count + } + return account + } + + private func post(_ url: String, _ form: [String: String]) async throws -> [String: Any] { + var request = URLRequest(url: URL(string: url)!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type") + var components = URLComponents() + components.queryItems = form.sorted { $0.key < $1.key }.map { + URLQueryItem(name: $0.key, value: $0.value) + } + request.httpBody = Data((components.percentEncodedQuery ?? "").utf8) + return try await send(request) + } + + private func get(_ url: String, token: String) async throws -> [String: Any] { + var request = URLRequest(url: URL(string: url)!) + request.setValue("application/json", forHTTPHeaderField: "Accept") + request.setValue(token, forHTTPHeaderField: "Authorization") + return try await send(request) + } + + private func send(_ request: URLRequest) async throws -> [String: Any] { + let (data, status) = try await transport(request) + let json = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] ?? [:] + // The device-flow errors arrive as 200s with an `error` field; anything else non-2xx + // is a real failure. + guard (200..<300).contains(status) || json["error"] != nil else { + throw Failure.unexpected("HTTP \(status)") + } + return json + } + + private static func describe(_ json: [String: Any]) -> String { + (json["error_description"] as? String) ?? (json["error"] as? String) ?? "unexpected reply" + } +} + +/// Display helpers for the sign-in card, kept apart from the view so tests pin them. +enum CopilotSignInText { + /// "14:12" — minutes and seconds until the code expires, never negative. + static func countdown(until expiry: Date, now: Date) -> String { + let remaining = max(0, Int(expiry.timeIntervalSince(now).rounded(.down))) + return String(format: "%d:%02d", remaining / 60, remaining % 60) + } + + /// "7 models available · premium requests 212 / 300 this month", leaving out what + /// GitHub did not say. + static func accountDetail(_ account: CopilotDeviceFlow.Account) -> String { + var parts: [String] = [] + if let count = account.modelCount { + parts.append("\(count) model\(count == 1 ? "" : "s") available") + } + if let used = account.premiumRequestsUsed, let limit = account.premiumRequestsLimit { + parts.append("premium requests \(used) / \(limit) this month") + } + return parts.joined(separator: " · ") + } +} diff --git a/graphcode/Sources/Clients/NodCredentials.swift b/graphcode/Sources/Clients/NodCredentials.swift new file mode 100644 index 00000000..3a85587b --- /dev/null +++ b/graphcode/Sources/Clients/NodCredentials.swift @@ -0,0 +1,171 @@ +import Foundation +import GraphcodeKit +import Security + +/// One secret Nod signs in with. Each is a generic-password item in the login Keychain +/// under `NodSettings.keychainService`, with the raw value as its account, never a file +/// under `~/.graphcode`. +enum NodCredential: String, CaseIterable, Sendable { + case anthropicAPIKey = "anthropic-api-key" + /// A Claude subscription token. Only stored when `NodClaudeSignIn.subscriptionLoginAllowed`. + case claudeSubscription = "claude-subscription" + case githubCopilot = "github-copilot" + + var engine: NodEngine { + switch self { + case .anthropicAPIKey, .claudeSubscription: .claudeAgentSDK + case .githubCopilot: .copilotSDK + } + } +} + +enum NodKeychainError: Error, Equatable { + case unexpectedStatus(OSStatus) +} + +/// Reads and writes Nod's secrets. The live value talks to the Keychain; tests use +/// `inMemory()` or a live store on a throwaway service. +struct NodCredentialStore: Sendable { + var read: @Sendable (NodCredential) throws -> String? + var write: @Sendable (String, NodCredential) throws -> Void + var delete: @Sendable (NodCredential) throws -> Void + + static let live = keychain(service: NodSettings.keychainService) + + static func keychain(service: String) -> NodCredentialStore { + let query = { @Sendable (credential: NodCredential) -> [CFString: Any] in + [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecAttrAccount: credential.rawValue, + ] + } + return NodCredentialStore( + read: { credential in + var item: CFTypeRef? + var search = query(credential) + search[kSecReturnData] = true + search[kSecMatchLimit] = kSecMatchLimitOne + let status = SecItemCopyMatching(search as CFDictionary, &item) + switch status { + case errSecSuccess: + return (item as? Data).flatMap { String(data: $0, encoding: .utf8) } + case errSecItemNotFound: + return nil + default: + throw NodKeychainError.unexpectedStatus(status) + } + }, + write: { secret, credential in + let data = Data(secret.utf8) + let update = SecItemUpdate( + query(credential) as CFDictionary, [kSecValueData: data] as CFDictionary) + switch update { + case errSecSuccess: + return + case errSecItemNotFound: + var add = query(credential) + add[kSecValueData] = data + add[kSecAttrAccessible] = kSecAttrAccessibleAfterFirstUnlock + add[kSecAttrLabel] = "GraphCode Nod" + let status = SecItemAdd(add as CFDictionary, nil) + guard status == errSecSuccess else { throw NodKeychainError.unexpectedStatus(status) } + default: + throw NodKeychainError.unexpectedStatus(update) + } + }, + delete: { credential in + let status = SecItemDelete(query(credential) as CFDictionary) + guard status == errSecSuccess || status == errSecItemNotFound else { + throw NodKeychainError.unexpectedStatus(status) + } + } + ) + } + + static func inMemory(_ seed: [NodCredential: String] = [:]) -> NodCredentialStore { + let box = LockedBox(seed) + return NodCredentialStore( + read: { credential in box.withValue { $0[credential] } }, + write: { secret, credential in box.withValue { $0[credential] = secret } }, + delete: { credential in box.withValue { $0[credential] = nil } } + ) + } + + /// Whether `engine` has something to sign in with. A Keychain error reads as signed + /// out, which is what the person would have to fix anyway. + func isSignedIn(_ engine: NodEngine) -> Bool { + NodCredential.allCases.contains { credential in + guard credential.engine == engine else { return false } + return ((try? read(credential)) ?? nil)?.isEmpty == false + } + } + + func signOut(_ engine: NodEngine) throws { + for credential in NodCredential.allCases where credential.engine == engine { + try delete(credential) + } + } +} + +private final class LockedBox: @unchecked Sendable { + private let lock = NSLock() + private var value: Value + + init(_ value: Value) { self.value = value } + + func withValue(_ body: (inout Value) -> T) -> T { + lock.lock() + defer { lock.unlock() } + return body(&value) + } +} + +/// The Claude half of Nod's sign-in. +/// +/// Anthropic does not allow third-party products built on the Claude Agent SDK to offer +/// claude.ai login unless Anthropic has approved it +/// (code.claude.com/docs/en/agent-sdk/overview), so "Continue with Claude" and reusing a +/// Claude Code sign-in are built but hidden behind `subscriptionLoginAllowed`. An API +/// key is the path that ships. +enum NodClaudeSignIn { + static let subscriptionLoginAllowed = false + + enum APIKeyProblem: Error, Equatable { + case empty + case notAnAnthropicKey + } + + /// Trims what was pasted and checks it looks like an Anthropic key, so a GitHub token + /// pasted into the wrong field is caught here rather than as a 401 mid-turn. + static func validateAPIKey(_ raw: String) -> Result { + let key = raw.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { return .failure(.empty) } + guard key.hasPrefix("sk-ant-"), key.count > 20, !key.contains(where: \.isWhitespace) else { + return .failure(.notAnAnthropicKey) + } + return .success(key) + } + + /// Whether Claude Code is signed in on this Mac: its Keychain item exists, or its + /// credentials file does. Asks for the item's attributes only, never its secret, so it + /// raises no Keychain prompt. + static func claudeCodeSignInFound( + home: URL = FileManager.default.homeDirectoryForCurrentUser, + keychainItemExists: (String) -> Bool = genericPasswordExists(service:) + ) -> Bool { + if keychainItemExists("Claude Code-credentials") { return true } + return FileManager.default.fileExists( + atPath: home.appending(path: ".claude/.credentials.json").path) + } + + static func genericPasswordExists(service: String) -> Bool { + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: service, + kSecReturnAttributes: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + return SecItemCopyMatching(query as CFDictionary, nil) == errSecSuccess + } +} diff --git a/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift b/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift index b6808c5c..3e63b99e 100644 --- a/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift +++ b/graphcode/Sources/Features/Canvas/LoopCardPresentation.swift @@ -25,6 +25,9 @@ struct LoopCardPresentation: Equatable { /// what it was. A launch-time modal asking about twenty forgotten branches is the /// version people learn to dismiss. case reclaim + /// A Nod loop waiting on a permission ask. Allow once only when the ask is + /// answerable from the card; otherwise the card sends you to the chat. + case nodAsk(NodCardDetail.Ask) } struct Progress: Equatable { @@ -47,18 +50,22 @@ struct LoopCardPresentation: Equatable { let detail: Detail /// The footer, already ordered: kind, branch, backend when it isn't the default, age. let meta: [String] + /// Nod's mark in the meta row. Kept even when Nod is the default and its name drops + /// off, because it says the card's quick actions work. + let showsNodGlyph: Bool init( node: LoopNode, now: Date = Date(), reclaimOffer: WorktreeAssessment? = nil, - summarising: Bool = LoopSummaryPresentation.isProducing + summarising: Bool = LoopSummaryPresentation.isProducing, nod: NodCardDetail? = nil ) { + showsNodGlyph = node.backend == .nod word = node.displayState.displayWord(for: node.loopType) if let reclaimOffer, node.isResolved { liveLine = Self.reclaimLine(reclaimOffer) detail = .reclaim } else { liveLine = Self.liveLine(node, summarising: summarising) - detail = Self.detail(node, now: now) + detail = Self.nodDetail(node, nod) ?? Self.detail(node, now: now) } meta = Self.meta(node, now: now) } @@ -152,6 +159,19 @@ struct LoopCardPresentation: Equatable { return .none } + /// A pending ask outranks everything, then the goal's clauses as a bar: "goal 1 / 2". + /// Resolved loops show neither, since their last check is already in the live line. + private static func nodDetail(_ node: LoopNode, _ nod: NodCardDetail?) -> Detail? { + guard node.backend == .nod, let nod, !node.isResolved else { return nil } + if let ask = nod.ask { return .nodAsk(ask) } + guard let met = nod.goalMet, let total = nod.goalTotal, total > 0 else { return nil } + let clamped = Swift.min(Swift.max(met, 0), total) + return .progress( + Progress( + fraction: Double(clamped) / Double(total), readings: "goal \(clamped) / \(total)", + change: clamped == total ? "all met" : "")) + } + private static func action(for state: LoopState) -> Action { // A question wants an answer typed into the session; a stuck wait wants a look at // what it is waiting on. Both open the loop, which is why the card takes one action. diff --git a/graphcode/Sources/Features/Canvas/LoopCardView.swift b/graphcode/Sources/Features/Canvas/LoopCardView.swift index 7a74e587..3eefec2d 100644 --- a/graphcode/Sources/Features/Canvas/LoopCardView.swift +++ b/graphcode/Sources/Features/Canvas/LoopCardView.swift @@ -38,6 +38,10 @@ struct LoopCardView: View { /// Set where the card can act on a template follow — `Detach` sits beside the /// Follows chip (PROMPT_TEMPLATES.md § Follow vs snapshot). var onDetachTemplate: (() -> Void)? + /// A Nod loop's goal clauses and open ask, when the node carries them. + var nod: NodCardDetail? + /// Allow once for an ask answerable from the card; the ask id is the argument. + var onAllowOnce: ((String) -> Void)? enum Metrics { static let size = CGSize(width: 250, height: 96) @@ -56,7 +60,7 @@ struct LoopCardView: View { private var needsAttention: Bool { reason != nil } var body: some View { - let card = LoopCardPresentation(node: node, now: now, reclaimOffer: reclaimOffer) + let card = LoopCardPresentation(node: node, now: now, reclaimOffer: reclaimOffer, nod: nod) return VStack(alignment: .leading, spacing: 6) { titleRow if entryRole == .unwired { @@ -70,7 +74,7 @@ struct LoopCardView: View { detail(card.detail) } Spacer(minLength: 0) - metaRow(card.meta) + metaRow(card.meta, nodGlyph: card.showsNodGlyph) } .padding(.top, 8) .padding(.horizontal, 11) @@ -196,6 +200,45 @@ struct LoopCardView: View { .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.white.opacity(0.5)) } + case .nodAsk(let ask): + nodAsk(ask) + } + } + + /// Allow once sits on the card only for allowlisted or read-only asks. Anything else + /// (a network fetch, a command outside the allowlist) opens the chat, so the person + /// sees what led to it before saying yes. + @ViewBuilder + private func nodAsk(_ ask: NodCardDetail.Ask) -> some View { + HStack(spacing: 7) { + if ask.answerableFromCard { + // Action blue, as in the chat pane: orange is the Needs-you state, which the pill + // and border already carry. + Button("Allow once") { onAllowOnce?(ask.askID) } + .buttonStyle(.plain) + .font(.system(size: 11, weight: .bold)) + .foregroundStyle(.white) + .padding(.vertical, 3) + .padding(.horizontal, 9) + .background(Theme.paneFocusTint, in: RoundedRectangle(cornerRadius: 5)) + Button("Review in chat", action: onPrimaryAction) + .buttonStyle(.plain) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.55)) + } else { + Button("Review in chat", action: onPrimaryAction) + .buttonStyle(.plain) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.white.opacity(0.85)) + .padding(.vertical, 3) + .padding(.horizontal, 7) + .background(.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 5)) + Text("\(ask.kind.cardLabel) · needs context") + .font(.system(size: 9.5, design: .monospaced)) + .minimumScaleFactor(0.8) + .foregroundStyle(.white.opacity(0.5)) + .lineLimit(1) + } } } @@ -218,7 +261,7 @@ struct LoopCardView: View { } } - private func metaRow(_ parts: [String]) -> some View { + private func metaRow(_ parts: [String], nodGlyph: Bool) -> some View { HStack(spacing: 5) { // A drawn square rather than the kind's SF Symbol: the glyphs have wildly // different optical weights, and this row is a line of 10.5pt mono that a stack @@ -226,6 +269,7 @@ struct LoopCardView: View { RoundedRectangle(cornerRadius: 1.5) .fill(node.loopType.accent) .frame(width: 6, height: 6) + if nodGlyph { NodGlyph().help("Nod: presence and quick actions are exact") } Text(parts.joined(separator: " · ")) .font(.system(size: 10.5, design: .monospaced)) .foregroundStyle(.white.opacity(0.52)) @@ -254,6 +298,18 @@ struct LoopCardView: View { } } +/// Nod's mark: a node, drawn monochrome so it never competes with the kind's accent. +/// Nothing in the graph gets its own hue for being Nod. +struct NodGlyph: View { + var body: some View { + ZStack { + Circle().stroke(.white.opacity(0.6), lineWidth: 1) + Circle().fill(.white.opacity(0.6)).frame(width: 3, height: 3) + } + .frame(width: 8, height: 8) + } +} + /// A following loop's mark: a 5pt blue dot and the name of what it follows, with /// "· missing" when the file could not be found at the last resolve. The blue is /// the action blue the Templates button uses — the follow is chrome on the loop, diff --git a/graphcode/Sources/Features/Canvas/NodCardDetail.swift b/graphcode/Sources/Features/Canvas/NodCardDetail.swift new file mode 100644 index 00000000..9fae2c36 --- /dev/null +++ b/graphcode/Sources/Features/Canvas/NodCardDetail.swift @@ -0,0 +1,66 @@ +import Foundation +import GraphcodeKit + +/// What a Nod loop's card knows beyond what every card knows: how many goal clauses the +/// evaluator last found met, and the permission ask nobody has answered yet. +/// +/// The live line is not here. It is `LoopNode.activity` like every backend's, which Nod +/// fills exactly from its SDK events ("Running swift test · turn 4", or "asks to run …" +/// while an ask is open). +struct NodCardDetail: Equatable { + struct Ask: Equatable { + var askID: String + var kind: NodPermissionKind + var subject: String + /// Allowlisted or read-only asks may be answered from the card; anything else opens + /// the chat, so the person sees what led to it. + var answerableFromCard: Bool + } + + var goalMet: Int? + var goalTotal: Int? + var ask: Ask? +} + +/// Where cards get `NodCardDetail` from. The daemon folds Nod's event log into node state +/// alongside presence; until that field reaches `LoopNode`, the provider has nothing to +/// say and Nod cards draw like any other. +@MainActor +protocol NodCardStateProviding { + func cardDetail(for node: LoopNode) -> NodCardDetail? +} + +/// Answers a permission ask from the card. Live, this sends `resolvePermission` over the +/// loop's control socket. +@MainActor +protocol NodPermissionAnswering { + func allowOnce(nodeID: UUID, askID: String) +} + +@MainActor +enum NodCardWiring { + static var provider: any NodCardStateProviding = Unavailable() + static var answerer: any NodPermissionAnswering = Unavailable() + + private struct Unavailable: NodCardStateProviding, NodPermissionAnswering { + func cardDetail(for node: LoopNode) -> NodCardDetail? { nil } + func allowOnce(nodeID: UUID, askID: String) {} + } + + static func detail(for node: LoopNode) -> NodCardDetail? { + node.backend == .nod ? provider.cardDetail(for: node) : nil + } +} + +extension NodPermissionKind { + /// The card's reason for sending an ask to the chat, e.g. "network · needs context". + var cardLabel: String { + switch self { + case .shell: "shell" + case .network: "network" + case .editOutsideWorktree: "outside worktree" + case .messageLoop: "message" + case .mcpTool: "MCP tool" + } + } +} diff --git a/graphcode/Sources/Features/Overview/GraphOverviewCards.swift b/graphcode/Sources/Features/Overview/GraphOverviewCards.swift index 8af620e7..0bdf935e 100644 --- a/graphcode/Sources/Features/Overview/GraphOverviewCards.swift +++ b/graphcode/Sources/Features/Overview/GraphOverviewCards.swift @@ -200,7 +200,9 @@ extension GraphOverviewView { onKeep: { store.send( .projects(.element(id: loop.projectPath, action: .keepWorktreeTapped(node.id)))) - } + }, + nod: NodCardWiring.detail(for: node), + onAllowOnce: { NodCardWiring.answerer.allowOnce(nodeID: node.id, askID: $0) } ) .contentShape(Rectangle()) .onTapGesture { open(loop) } diff --git a/graphcode/Sources/Features/Project/NodeDraftForm.swift b/graphcode/Sources/Features/Project/NodeDraftForm.swift index a7d412a9..bb4addb9 100644 --- a/graphcode/Sources/Features/Project/NodeDraftForm.swift +++ b/graphcode/Sources/Features/Project/NodeDraftForm.swift @@ -26,6 +26,8 @@ import SwiftUI /// whose fields are load-bearing would drift. struct NodeDraftForm: View { @Bindable var store: StoreOf + @State private var nodSignedIn = false + private var nodSettings: NodSettings { SettingsModel.shared.settings.nod } /// Whether this graph's repository lives on another machine — see /// `RemoteProjectLocation`. Drives which bindings the form can honestly offer. @@ -143,26 +145,26 @@ struct NodeDraftForm: View { DraftField( label: "Agent", fromTemplate: store.templateSetFields.contains(.backend) ) { - Picker("", selection: $store.draftBackend) { - ForEach(CLISessionBackendKind.allCases, id: \.self) { backend in - Text(backend.displayName).tag(backend) - } - } - .labelsHidden() + RunsAsAgentMenu( + backend: $store.draftBackend, modelTier: $store.draftModelTier, + loopType: store.draftLoopType, nodSignedIn: nodSignedIn) } - DraftField(label: "Model") { - Picker( - "", - selection: Binding( - get: { store.draftModelTier ?? .standard }, - set: { store.draftModelTier = $0 } - ) - ) { - ForEach(ModelTier.allCases, id: \.self) { tier in - Text(tier.displayName).tag(tier) + // Nod's model is chosen in the agent menu, where its per-type default is named. + if store.draftBackend != .nod { + DraftField(label: "Model") { + Picker( + "", + selection: Binding( + get: { store.draftModelTier ?? .standard }, + set: { store.draftModelTier = $0 } + ) + ) { + ForEach(ModelTier.allCases, id: \.self) { tier in + Text(tier.displayName).tag(tier) + } } + .labelsHidden() } - .labelsHidden() } if !isRemoteProject && !store.graph.isGlobal { DraftField( @@ -198,6 +200,7 @@ struct NodeDraftForm: View { backend: store.draftBackend, loopType: store.draftLoopType)) } } + .task { nodSignedIn = NodCredentialStore.live.isSignedIn(nodSettings.engine) } .onChange(of: store.draftLoopType) { _, newValue in // Changing the type can invalidate a backend that was fine a moment ago. Falling // back beats leaving an impossible pairing selected and refusing to submit with no diff --git a/graphcode/Sources/Features/Project/ProjectCanvasCards.swift b/graphcode/Sources/Features/Project/ProjectCanvasCards.swift index c1951b9f..c2e492db 100644 --- a/graphcode/Sources/Features/Project/ProjectCanvasCards.swift +++ b/graphcode/Sources/Features/Project/ProjectCanvasCards.swift @@ -48,7 +48,9 @@ extension ProjectCanvasView { }, onKeep: { store.send(.keepWorktreeTapped(node.id)) }, onDetachTemplate: node.templateFollow == nil - ? nil : { store.send(.detachTemplateTapped(node.id)) } + ? nil : { store.send(.detachTemplateTapped(node.id)) }, + nod: NodCardWiring.detail(for: node), + onAllowOnce: { NodCardWiring.answerer.allowOnce(nodeID: node.id, askID: $0) } ) .contentShape(Rectangle()) // A composite has no session of its own to open (`LoopNode.firstInstruction` is nil diff --git a/graphcode/Sources/Features/Project/RunsAsAgentMenu.swift b/graphcode/Sources/Features/Project/RunsAsAgentMenu.swift new file mode 100644 index 00000000..066ecdd1 --- /dev/null +++ b/graphcode/Sources/Features/Project/RunsAsAgentMenu.swift @@ -0,0 +1,127 @@ +import GraphcodeKit +import SwiftUI + +/// The new-loop form's agent menu (design 6a), grouped by what opens when the loop does: +/// Chat (Nod, with its engine and model) or Terminal (the CLIs). A backend that can't host +/// the chosen loop type stays listed but greyed, with the missing capability beside it, +/// because "why can't I use Pi for this?" is answered by the row, not by its absence. +struct RunsAsAgentMenu: View { + @Binding var backend: CLISessionBackendKind + @Binding var modelTier: ModelTier? + let loopType: LoopType + var nodSettings: NodSettings = SettingsModel.shared.settings.nod + /// Read once by the form rather than here: a Keychain query per redraw of the form + /// would be a query per keystroke in its text fields. + var nodSignedIn: Bool + + var body: some View { + Menu { + ForEach( + AgentMenuSection.sections(for: loopType, nodEnabled: FeatureRamps.isEnabled(.nod)), + id: \.surface + ) { section in + Section(section.title) { + ForEach(section.entries, id: \.backend) { entry in + if entry.backend == .nod { + nodItems(entry) + } else { + Button { + backend = entry.backend + } label: { + Text(entry.label) + if let note = entry.note { Text(note) } + } + .disabled(!entry.isEnabled) + } + } + } + } + } label: { + Text( + AgentMenuSection.label( + backend: backend, tier: modelTier, loopType: loopType, nod: nodSettings)) + } + } + + @ViewBuilder + private func nodItems(_ entry: AgentMenuSection.Entry) -> some View { + Button { + backend = .nod + } label: { + Text(entry.label) + Text(nodSettings.engine.displayName + (nodSignedIn ? " ✓" : " · not signed in")) + } + .disabled(!entry.isEnabled) + Menu("Model") { + Picker("Model", selection: $modelTier) { + Text("Default · \(nodSettings.resolvedModel(for: loopType).displayName)") + .tag(ModelTier?.none) + ForEach(ModelTier.allCases, id: \.self) { tier in + Text(NodModelCatalog.model(for: tier, engine: nodSettings.engine).displayName) + .tag(ModelTier?.some(tier)) + } + } + .pickerStyle(.inline) + } + .disabled(!entry.isEnabled) + } +} + +/// The menu's content as plain values, so the grouping and greying are checked without +/// drawing a menu. +struct AgentMenuSection: Equatable { + struct Entry: Equatable { + let backend: CLISessionBackendKind + let isEnabled: Bool + /// The capability that's missing for this loop type, e.g. "no sub-agents". + let note: String? + + var label: String { backend.displayName } + } + + let surface: AgentSurface + let entries: [Entry] + + var title: String { + switch surface { + case .chat: "Chat" + case .terminal: "Terminal" + } + } + + /// The Chat group exists only while Nod's beta ramp is on; an empty group is dropped. + static func sections(for loopType: LoopType, nodEnabled: Bool = true) -> [AgentMenuSection] { + AgentSurface.allCases.compactMap { surface in + let backends = CLISessionBackendKind.agentsOffered(nodEnabled: nodEnabled) + .filter { $0.surface == surface } + guard !backends.isEmpty else { return nil } + return AgentMenuSection( + surface: surface, + entries: + backends + .map { backend in + Entry( + backend: backend, isEnabled: backend.canHost(loopType), + note: backend.canHost(loopType) ? nil : missingNote(backend, loopType)) + }) + } + } + + static func missingNote(_ backend: CLISessionBackendKind, _ loopType: LoopType) -> String { + guard backend.isSpiked else { return "not available yet" } + switch loopType { + case .goalBased: return "no goal mode" + case .timeBased: return "no recurrence" + case .composite: return "no sub-agents" + case .sketch, .turnBased: return "can't host this type" + } + } + + /// "Nod · Sonnet" for Nod, whose model is part of the choice; the CLI's name otherwise. + static func label( + backend: CLISessionBackendKind, tier: ModelTier?, loopType: LoopType, nod: NodSettings + ) -> String { + guard backend == .nod else { return backend.displayName } + return "Nod · " + nod.resolvedModel(for: loopType, tier: tier).displayName + } +} diff --git a/graphcode/Sources/Features/Settings/AgentSettings.swift b/graphcode/Sources/Features/Settings/AgentSettings.swift new file mode 100644 index 00000000..ea17db2c --- /dev/null +++ b/graphcode/Sources/Features/Settings/AgentSettings.swift @@ -0,0 +1,96 @@ +import GraphcodeKit +import SwiftUI + +enum SettingsPane: Hashable { + case general + case agent(CLISessionBackendKind) + case templates +} + +extension CLISessionBackendKind { + /// Nod first, then the CLIs in the order the design lists them. + static let settingsOrder: [CLISessionBackendKind] = [ + .nod, .claudeCode, .codex, .copilotCLI, .openCode, .pi, + ] + + /// `settingsOrder` without Nod while its beta ramp is off for this install. + static func agentsOffered(nodEnabled: Bool = FeatureRamps.isEnabled(.nod)) + -> [CLISessionBackendKind] + { + settingsOrder.filter { $0 != .nod || nodEnabled } + } +} + +/// Settings › Agents › one CLI: what it may do without asking, and for Copilot the version +/// to launch. Each loop runs whether or not this window is open, so nobody is there to +/// answer a permission prompt. +struct CLIAgentSettingsPane: View { + let backend: CLISessionBackendKind + @Binding var settings: GraphcodeSettings + + var body: some View { + Form { + Section { + permissionPicker + } header: { + Text("Permissions") + } footer: { + Text( + "A loop runs whether or not this window is open, so nobody is there to answer a " + + "permission prompt. A backend left on its own default waits at that prompt " + + "while the graph reports the loop as running." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + if backend == .copilotCLI { + PreferredVersionsSettingsSection(settings: $settings) + } + } + .formStyle(.grouped) + } + + @ViewBuilder private var permissionPicker: some View { + switch backend { + case .claudeCode: + picker($settings.claudePermissionMode, explanation: settings.claudePermissionMode.explanation) + { + $0.displayName + } + case .copilotCLI: + picker($settings.copilotPermissions, explanation: settings.copilotPermissions.explanation) { + $0.displayName + } + case .codex: + picker($settings.codexApprovals, explanation: settings.codexApprovals.explanation) { + $0.displayName + } + case .openCode: + picker( + $settings.openCodePermissions, explanation: settings.openCodePermissions.explanation + ) { $0.displayName } + case .pi: + picker($settings.piProjectTrust, explanation: settings.piProjectTrust.explanation) { + $0.displayName + } + case .nod: + EmptyView() + } + } + + private func picker( + _ selection: Binding, explanation: String, name: @escaping (Mode) -> String + ) -> some View where Mode.AllCases: RandomAccessCollection { + Group { + Picker(backend.displayName, selection: selection) { + ForEach(Array(Mode.allCases), id: \.self) { mode in + Text(name(mode)).tag(mode) + } + } + Text(explanation) + .font(.caption2) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } +} diff --git a/graphcode/Sources/Features/Settings/Nod/NodMCPServers.swift b/graphcode/Sources/Features/Settings/Nod/NodMCPServers.swift new file mode 100644 index 00000000..a9cc2b87 --- /dev/null +++ b/graphcode/Sources/Features/Settings/Nod/NodMCPServers.swift @@ -0,0 +1,59 @@ +import Foundation +import GraphcodeKit + +/// One row of Settings › Agents › Nod › MCP servers. +struct NodMCPServer: Equatable, Identifiable { + enum Source: Equatable { + /// graphcode's own server: siblings, edges, the mailroom. Always on. + case builtIn + /// A project's `.mcp.json`; the associated value is the project's name. + case project(String) + } + + var name: String + var source: Source + /// A remote (http/sse) server that sends no Authorization header of its own, so it + /// will want an OAuth sign-in before Nod can call it. + var needsSignIn: Bool + + var id: String { name } + + static let graphcode = NodMCPServer(name: "graphcode", source: .builtIn, needsSignIn: false) + + /// The built-in server, then every server the known projects' `.mcp.json` files + /// declare, first project wins on a name clash. Settings has no project of its own, + /// so "known" is the recent-projects list, the same rule the Templates section uses. + static func load(projects: [ProjectRef]) -> [NodMCPServer] { + var servers = [graphcode] + for project in projects where !project.path.contains("://") { + let file = URL(fileURLWithPath: project.path).appending(path: ".mcp.json") + guard let data = try? Data(contentsOf: file) else { continue } + for server in parse(data, projectName: project.name) + where !servers.contains(where: { $0.name == server.name }) { + servers.append(server) + } + } + return servers + } + + /// Reads `{"mcpServers": {name: {type?, command?, url?, headers?}}}`, the shape Claude + /// Code and the Agent SDK share. Anything unreadable yields no rows rather than an + /// error: a broken `.mcp.json` is the CLIs' problem to report too. + static func parse(_ data: Data, projectName: String) -> [NodMCPServer] { + guard + let root = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], + let declared = root["mcpServers"] as? [String: Any] + else { return [] } + return declared.keys.sorted().compactMap { name in + guard name != graphcode.name, let config = declared[name] as? [String: Any] else { + return nil + } + let type = (config["type"] as? String)?.lowercased() + let isRemote = type == "http" || type == "sse" || (type == nil && config["url"] != nil) + let headers = (config["headers"] as? [String: Any]) ?? [:] + let authorises = headers.keys.contains { $0.lowercased() == "authorization" } + return NodMCPServer( + name: name, source: .project(projectName), needsSignIn: isRemote && !authorises) + } + } +} diff --git a/graphcode/Sources/Features/Settings/Nod/NodSettingsPane.swift b/graphcode/Sources/Features/Settings/Nod/NodSettingsPane.swift new file mode 100644 index 00000000..e1e71129 --- /dev/null +++ b/graphcode/Sources/Features/Settings/Nod/NodSettingsPane.swift @@ -0,0 +1,304 @@ +import GraphcodeKit +import SwiftUI + +/// Settings › Agents › Nod (design 7a): engine and models, permissions, the shell +/// allowlist, the spend cap and MCP servers, all bound to `GraphcodeSettings.nod`. The +/// runtime reads the same file, so what this pane says is what Nod does. +struct NodSettingsPane: View { + @Binding var settings: NodSettings + @Bindable var setup: NodSetupModel + var mcpServers: [NodMCPServer] + + @State private var newPattern = "" + @State private var showsSetup = false + + var body: some View { + Form { + engineSection + modelsSection + permissionsSection + allowlistSection + spendSection + mcpSection + } + .formStyle(.grouped) + .sheet(isPresented: $showsSetup) { + NodSetupView(model: setup) { showsSetup = false } + .preferredColorScheme(.dark) + } + } + + private var engineSection: some View { + Section { + ForEach(NodEngine.allCases, id: \.self) { engine in + HStack(spacing: 10) { + Image(systemName: settings.engine == engine ? "largecircle.fill.circle" : "circle") + .foregroundStyle(settings.engine == engine ? Color.accentColor : .secondary) + VStack(alignment: .leading, spacing: 1) { + Text(engine.displayName) + Text(NodModelCatalog.familySummary(for: engine)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + if setup.isSignedIn(engine) { + Text("signed in").font(.caption).foregroundStyle(NodSetupInk.ok) + Button("Sign out") { setup.signOut(engine) } + } else { + Text("not signed in").font(.caption).foregroundStyle(.secondary) + Button("Sign in…") { + settings.switchEngine(to: engine) + showsSetup = true + } + } + } + .contentShape(Rectangle()) + .onTapGesture { settings.switchEngine(to: engine) } + } + } header: { + Text("Engine & models") + } footer: { + Text( + "New loops use the selected engine. Existing loops keep the engine they started on." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private var modelsSection: some View { + Section { + ForEach(LoopType.allCases, id: \.self) { loopType in + Picker(loopType.displayName, selection: modelBinding(for: loopType)) { + modelOptions + } + } + Picker("Composite children", selection: childBinding) { modelOptions } + Picker("Goal evaluator", selection: evaluatorBinding) { modelOptions } + } header: { + Text("Default model per loop type") + } footer: { + Text( + "A model picked in the new-loop menu wins. The goal evaluator checks each clause of " + + "a goal every time Nod tries to stop, so a fast model is usually enough." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder private var modelOptions: some View { + ForEach(NodModelCatalog.models(for: settings.engine)) { model in + Text(model.displayName).tag(model) + } + } + + private var permissionsSection: some View { + Section { + LabeledContent("Read files & search") { Text("Always").foregroundStyle(.secondary) } + Picker("Edit files in the loop's worktree", selection: $settings.editsInWorktree) { + ForEach(NodSettings.EditPolicy.allCases, id: \.self) { Text($0.displayName).tag($0) } + } + Picker("Edit outside the worktree", selection: $settings.editsOutsideWorktree) { + askOptions + } + Picker("Shell commands", selection: $settings.shell) { askOptions } + Picker("Message other loops", selection: $settings.messagesOtherLoops) { + ForEach(NodSettings.MessagePolicy.allCases, id: \.self) { Text($0.displayName).tag($0) } + } + Picker("Network", selection: $settings.network) { askOptions } + } header: { + Text("Permissions") + } footer: { + Text(settings.editsInWorktree.explanation) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder private var askOptions: some View { + ForEach(NodSettings.Ask.allCases, id: \.self) { Text($0.displayName).tag($0) } + } + + private var allowlistSection: some View { + Section { + ForEach(settings.shellAllowlist, id: \.self) { pattern in + HStack { + Text(pattern).font(.system(.body, design: .monospaced)) + Spacer() + Button { + settings.removeAllowlistPattern(pattern) + } label: { + Image(systemName: "minus.circle") + } + .buttonStyle(.borderless) + .help("Remove \(pattern)") + } + } + HStack { + TextField("Add a pattern, e.g. npm test *", text: $newPattern) + .font(.system(.body, design: .monospaced)) + .onSubmit(addPattern) + Button("Add", action: addPattern) + .disabled(newPattern.trimmingCharacters(in: .whitespaces).isEmpty) + } + } header: { + Text("Shell allowlist") + } footer: { + Text( + "Commands matching a pattern run without asking, and their asks can be allowed from " + + "the canvas card. Unattended types (Timed, and Composite children) can't stop to " + + "ask. A command they'd have to ask about fails the run and shows up on the card. " + + "It never waits silently." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private var spendSection: some View { + Section { + LabeledContent("Cap per run") { + HStack(spacing: 4) { + Text("$") + TextField( + "", value: spendBinding, format: .number.precision(.fractionLength(2)) + ) + .frame(width: 70) + .multilineTextAlignment(.trailing) + } + } + } header: { + Text("Spend cap") + } footer: { + Text( + "Applies to Timed loops and Composite children; 0 is no cap. A loop that reaches it " + + "stops and says so on its card. Copilot reports premium requests instead of " + + "dollars and is capped by its plan." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + private var mcpSection: some View { + Section { + ForEach(mcpServers) { server in + HStack { + VStack(alignment: .leading, spacing: 1) { + Text(server.name) + Text(Self.mcpDetail(server)) + .font(.caption) + .foregroundStyle(.secondary) + } + Spacer() + switch server.source { + case .builtIn: + Text("always on").font(.caption).foregroundStyle(.secondary) + case .project: + if server.needsSignIn { + Text("needs sign-in").font(.caption).foregroundStyle(NodSetupInk.warning) + Button("Connect") {} + .disabled(true) + .help("Nod runs the server's sign-in the first time it starts in that project.") + } + Toggle("", isOn: mcpBinding(server.name)) + .labelsHidden() + .toggleStyle(.switch) + .controlSize(.small) + } + } + } + } header: { + Text("MCP servers") + } footer: { + Text( + "The graphcode server is how Nod sees the graph. It's read-only plus ask and handoff, " + + "both gated by the permissions above." + ) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + + static func mcpDetail(_ server: NodMCPServer) -> String { + switch server.source { + case .builtIn: return "built in · siblings, edges, mailroom" + case .project(let name): return "from .mcp.json · \(name)" + } + } + + private func addPattern() { + if settings.addAllowlistPattern(newPattern) { newPattern = "" } + } + + private func modelBinding(for loopType: LoopType) -> Binding { + Binding( + get: { settings.resolvedModel(for: loopType) }, + set: { settings.setModel($0, for: loopType) }) + } + + private var childBinding: Binding { + Binding( + get: { settings.resolvedCompositeChildModel }, + set: { settings.compositeChildModel = $0.id }) + } + + private var evaluatorBinding: Binding { + Binding( + get: { settings.resolvedGoalEvaluatorModel }, + set: { settings.goalEvaluatorModel = $0.id }) + } + + private var spendBinding: Binding { + Binding(get: { settings.spendCapUSD }, set: { settings.setSpendCap($0) }) + } + + private func mcpBinding(_ name: String) -> Binding { + Binding( + get: { !settings.disabledMCPServers.contains(name) }, + set: { isOn in + settings.disabledMCPServers.removeAll { $0 == name } + if !isOn { settings.disabledMCPServers.append(name) } + }) + } +} + +extension NodSettings.Ask { + var displayName: String { + switch self { + case .always: "Always" + case .ask: "Ask" + case .never: "Never" + } + } +} + +extension NodSettings.EditPolicy { + var displayName: String { + switch self { + case .reviewHunks: "Review hunks" + case .auto: "Auto" + } + } + + var explanation: String { + switch self { + case .reviewHunks: + "Edits are staged, not written. A hunk lands in the loop's worktree only when you " + + "accept it." + case .auto: + "Edits arrive already accepted and stay reviewable until the turn ends." + } + } +} + +extension NodSettings.MessagePolicy { + var displayName: String { + switch self { + case .draftForMe: "Draft for me" + case .send: "Send" + case .never: "Never" + } + } +} diff --git a/graphcode/Sources/Features/Settings/Nod/NodSetupModel.swift b/graphcode/Sources/Features/Settings/Nod/NodSetupModel.swift new file mode 100644 index 00000000..f81361df --- /dev/null +++ b/graphcode/Sources/Features/Settings/Nod/NodSetupModel.swift @@ -0,0 +1,163 @@ +import AppKit +import Foundation +import GraphcodeKit +import Observation + +/// The state behind "Set up Nod", shared by the Welcome sheet and Settings › Agents › Nod: +/// which engine, whether each one is signed in, the API-key field, and the Copilot +/// device flow's progress. +/// +/// Settings are read and written through `settings`, which the app points at +/// `SettingsModel.shared` so a choice here is saved the moment it is made. +@MainActor +@Observable +final class NodSetupModel { + enum CopilotPhase: Equatable { + case idle + case requesting + case waiting(CopilotDeviceFlow.DeviceCode) + case signedIn(CopilotDeviceFlow.Account?) + case failed(String) + } + + var settings: NodSettings { + get { readSettings() } + set { writeSettings(newValue) } + } + + var apiKeyDraft = "" + var apiKeyError: String? + var showsAPIKeyField = false + var copilotPhase: CopilotPhase = .idle + private(set) var signedIn: [NodEngine: Bool] = [:] + private(set) var claudeCodeSignInFound = false + + @ObservationIgnored private let credentials: NodCredentialStore + @ObservationIgnored private let deviceFlow: CopilotDeviceFlow + @ObservationIgnored private let readSettings: () -> NodSettings + @ObservationIgnored private let writeSettings: (NodSettings) -> Void + @ObservationIgnored private let openURL: (URL) -> Void + @ObservationIgnored private var copilotTask: Task? + + init( + credentials: NodCredentialStore = .live, + deviceFlow: CopilotDeviceFlow = .live, + readSettings: @escaping () -> NodSettings = { SettingsModel.shared.settings.nod }, + writeSettings: @escaping (NodSettings) -> Void = { SettingsModel.shared.settings.nod = $0 }, + openURL: @escaping (URL) -> Void = { NSWorkspace.shared.open($0) }, + claudeCodeSignInFound: () -> Bool = { NodClaudeSignIn.claudeCodeSignInFound() } + ) { + self.credentials = credentials + self.deviceFlow = deviceFlow + self.readSettings = readSettings + self.writeSettings = writeSettings + self.openURL = openURL + self.claudeCodeSignInFound = + NodClaudeSignIn.subscriptionLoginAllowed && claudeCodeSignInFound() + refreshSignIn() + } + + func isSignedIn(_ engine: NodEngine) -> Bool { signedIn[engine] ?? false } + + func selectEngine(_ engine: NodEngine) { + settings.switchEngine(to: engine) + } + + func refreshSignIn() { + for engine in NodEngine.allCases { + signedIn[engine] = credentials.isSignedIn(engine) + } + if isSignedIn(.copilotSDK), copilotPhase == .idle { + copilotPhase = .signedIn(nil) + } + } + + func saveAPIKey() { + switch NodClaudeSignIn.validateAPIKey(apiKeyDraft) { + case .failure(.empty): + apiKeyError = "Paste a key from console.anthropic.com." + case .failure(.notAnAnthropicKey): + apiKeyError = "That doesn't look like an Anthropic API key (sk-ant-…)." + case .success(let key): + do { + try credentials.write(key, .anthropicAPIKey) + apiKeyDraft = "" + apiKeyError = nil + showsAPIKeyField = false + } catch { + apiKeyError = "The Keychain refused the key (\(error))." + } + } + refreshSignIn() + } + + func signOut(_ engine: NodEngine) { + try? credentials.signOut(engine) + if engine == .copilotSDK { + copilotTask?.cancel() + copilotPhase = .idle + } + refreshSignIn() + } + + /// Requests a device code, opens github.com/login/device, and polls in the background + /// until GitHub answers. Starting again cancels a sign-in already in flight. + func startCopilotSignIn() { + copilotTask?.cancel() + copilotPhase = .requesting + let flow = deviceFlow + let credentials = credentials + copilotTask = Task { [weak self] in + do { + let code = try await flow.requestCode() + guard let self, !Task.isCancelled else { return } + self.copilotPhase = .waiting(code) + self.openURL(code.verificationURL) + let token = try await flow.pollForToken(code) + try credentials.write(token, .githubCopilot) + let account = try? await flow.account(token: token) + guard !Task.isCancelled else { return } + self.copilotPhase = .signedIn(account) + self.refreshSignIn() + } catch is CancellationError { + } catch { + guard let self, !Task.isCancelled else { return } + self.copilotPhase = .failed(Self.describe(error)) + } + } + } + + func cancelCopilotSignIn() { + copilotTask?.cancel() + copilotPhase = isSignedIn(.copilotSDK) ? .signedIn(nil) : .idle + } + + /// Waits for the sign-in started by `startCopilotSignIn`, for tests. + func waitForCopilotSignIn() async { + await copilotTask?.value + } + + func copyToPasteboard(_ text: String) { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(text, forType: .string) + } + + static func describe(_ error: Error) -> String { + switch error as? CopilotDeviceFlow.Failure { + case .notConfigured: + return "This build has no GitHub sign-in configured." + case .expired: + return "The code expired before GitHub saw it. Try again for a new one." + case .denied: + return "GitHub sign-in was cancelled." + case .unexpected(let detail): + return "GitHub sign-in failed: \(detail)" + case nil: + return "GitHub sign-in failed: \(error.localizedDescription)" + } + } +} + +extension NodSetupModel: Identifiable { + nonisolated var id: ObjectIdentifier { ObjectIdentifier(self) } +} diff --git a/graphcode/Sources/Features/Settings/Nod/NodSetupView.swift b/graphcode/Sources/Features/Settings/Nod/NodSetupView.swift new file mode 100644 index 00000000..b1119fc6 --- /dev/null +++ b/graphcode/Sources/Features/Settings/Nod/NodSetupView.swift @@ -0,0 +1,280 @@ +import GraphcodeKit +import SwiftUI + +/// "Set up Nod" (design 5a): pick the engine Nod thinks with, then sign in to it. Shown as +/// a sheet from the Welcome tour's agent page and from Settings › Agents › Nod. +struct NodSetupView: View { + @Bindable var model: NodSetupModel + var onDone: (() -> Void)? + + var body: some View { + VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text("Set up Nod") + .font(.system(size: 22, weight: .bold)) + Text( + "GraphCode's built-in agent. Pick the engine it thinks with. You can switch later, " + + "and existing loops keep the engine they started on." + ) + .font(.system(size: 13)) + .foregroundStyle(.white.opacity(0.65)) + .fixedSize(horizontal: false, vertical: true) + } + + HStack(alignment: .top, spacing: 10) { + ForEach(NodEngine.allCases, id: \.self) { engine in + NodEngineCard( + engine: engine, + isSelected: model.settings.engine == engine, + isSignedIn: model.isSignedIn(engine) + ) { model.selectEngine(engine) } + } + } + .fixedSize(horizontal: false, vertical: true) + + VStack(alignment: .leading, spacing: 10) { + Text("SIGN IN") + .font(.system(size: 10, weight: .bold)) + .tracking(0.6) + .foregroundStyle(.white.opacity(0.55)) + switch model.settings.engine { + case .claudeAgentSDK: NodClaudeSignInSection(model: model) + case .copilotSDK: NodCopilotSignInCard(model: model) + } + } + + Text( + "Keys go in the macOS Keychain, not in ~/.graphcode. Nod reads the project's existing " + + "CLAUDE.md, AGENTS.md and MCP config, so it starts out knowing what the CLIs know." + ) + .font(.system(size: 11)) + .foregroundStyle(.white.opacity(0.55)) + .fixedSize(horizontal: false, vertical: true) + + if let onDone { + HStack { + Spacer() + Button("Done", action: onDone) + .keyboardShortcut(.defaultAction) + } + } + } + .padding(22) + .frame(width: 520) + } +} + +struct NodEngineCard: View { + let engine: NodEngine + let isSelected: Bool + let isSignedIn: Bool + let select: () -> Void + + var body: some View { + Button(action: select) { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(engine.displayName) + .font(.system(size: 13, weight: .semibold)) + Spacer(minLength: 4) + if isSignedIn { + Image(systemName: "checkmark") + .font(.system(size: 10.5, weight: .semibold)) + .foregroundStyle(NodSetupInk.ok) + .help("Signed in") + } + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Theme.paneFocusTint : .white.opacity(0.25)) + } + Text(Self.blurb(engine)) + .font(.system(size: 11.5)) + .foregroundStyle(.white.opacity(0.6)) + .fixedSize(horizontal: false, vertical: true) + Text(NodModelCatalog.familySummary(for: engine)) + .font(.system(size: 11, design: .monospaced)) + .foregroundStyle(.white.opacity(0.55)) + .padding(.top, 4) + } + .padding(13) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background( + isSelected ? Theme.paneFocusTint.opacity(0.1) : Color.white.opacity(0.03), + in: RoundedRectangle(cornerRadius: 11) + ) + .overlay { + RoundedRectangle(cornerRadius: 11) + .stroke( + isSelected ? Theme.paneFocusTint : .white.opacity(0.08), + lineWidth: isSelected ? 1.5 : 1) + } + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + + static func blurb(_ engine: NodEngine) -> String { + switch engine { + case .claudeAgentSDK: + return NodClaudeSignIn.subscriptionLoginAllowed + ? "Claude models. Sign in with a Claude subscription or an Anthropic API key." + : "Claude models. Sign in with an Anthropic API key." + case .copilotSDK: + return "Your Copilot plan's models, billed to that seat. Sign in with GitHub." + } + } +} + +struct NodClaudeSignInSection: View { + @Bindable var model: NodSetupModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + if model.isSignedIn(.claudeAgentSDK) { + NodSignedInRow(text: "Signed in with an Anthropic API key") { + model.signOut(.claudeAgentSDK) + } + } else { + HStack(spacing: 8) { + if NodClaudeSignIn.subscriptionLoginAllowed { + Button("Continue with Claude") {} + .buttonStyle(.borderedProminent) + } + Button("Use an API key") { model.showsAPIKeyField = true } + } + if model.showsAPIKeyField { + HStack(spacing: 8) { + SecureField("sk-ant-…", text: $model.apiKeyDraft) + .textFieldStyle(.roundedBorder) + .onSubmit { model.saveAPIKey() } + Button("Save") { model.saveAPIKey() } + } + if let error = model.apiKeyError { + Text(error) + .font(.system(size: 11)) + .foregroundStyle(NodSetupInk.warning) + } + } + if model.claudeCodeSignInFound { + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill").foregroundStyle(NodSetupInk.ok) + Text("Found a Claude Code sign-in on this Mac. Use it for Nod too?") + .font(.system(size: 12)) + Spacer(minLength: 4) + Button("Use it") {} + } + .padding(10) + .background(.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 8)) + } + } + } + } +} + +/// Design 5b: the device code, Copy, the countdown, and the success state that replaces +/// the code once GitHub confirms. +struct NodCopilotSignInCard: View { + @Bindable var model: NodSetupModel + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + switch model.copilotPhase { + case .idle: + Button("Sign in to GitHub") { model.startCopilotSignIn() } + .buttonStyle(.borderedProminent) + case .requesting: + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text("Asking GitHub for a code…").font(.system(size: 12)) + } + case .waiting(let code): + waiting(code) + case .signedIn(let account): + signedIn(account) + case .failed(let message): + Text(message) + .font(.system(size: 12)) + .foregroundStyle(NodSetupInk.warning) + Button("Try again") { model.startCopilotSignIn() } + } + } + } + + private func waiting(_ code: CopilotDeviceFlow.DeviceCode) -> some View { + VStack(alignment: .leading, spacing: 10) { + Text( + "We've opened \(code.verificationURL.host() ?? "github.com")\(code.verificationURL.path()). Enter this code there:" + ) + .font(.system(size: 12)) + .foregroundStyle(.white.opacity(0.75)) + HStack(spacing: 12) { + Text(code.userCode) + .font(.system(size: 24, weight: .semibold, design: .monospaced)) + .tracking(2) + .textSelection(.enabled) + Button("Copy") { model.copyToPasteboard(code.userCode) } + } + .padding(.vertical, 10) + .padding(.horizontal, 14) + .background(.white.opacity(0.05), in: RoundedRectangle(cornerRadius: 9)) + TimelineView(.periodic(from: .now, by: 1)) { context in + HStack(spacing: 8) { + ProgressView().controlSize(.small) + Text( + "Waiting for GitHub… code expires in " + + CopilotSignInText.countdown(until: code.expiresAt, now: context.date) + ) + .font(.system(size: 11.5).monospacedDigit()) + .foregroundStyle(.white.opacity(0.6)) + Spacer(minLength: 4) + Button("Cancel") { model.cancelCopilotSignIn() } + .buttonStyle(.plain) + .font(.system(size: 11.5)) + .foregroundStyle(.white.opacity(0.6)) + } + } + } + } + + private func signedIn(_ account: CopilotDeviceFlow.Account?) -> some View { + HStack(alignment: .top, spacing: 10) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 16)) + .foregroundStyle(NodSetupInk.ok) + VStack(alignment: .leading, spacing: 3) { + Text( + [account.map { "Signed in as \($0.login)" } ?? "Signed in to GitHub", account?.planName] + .compactMap { $0 }.joined(separator: " · ") + ) + .font(.system(size: 12.5, weight: .medium)) + if let account, !CopilotSignInText.accountDetail(account).isEmpty { + Text(CopilotSignInText.accountDetail(account)) + .font(.system(size: 11.5)) + .foregroundStyle(.white.opacity(0.6)) + } + } + Spacer(minLength: 4) + Button("Sign out") { model.signOut(.copilotSDK) } + } + .padding(10) + .background(.white.opacity(0.04), in: RoundedRectangle(cornerRadius: 8)) + } +} + +struct NodSignedInRow: View { + let text: String + let signOut: () -> Void + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "checkmark.circle.fill").foregroundStyle(NodSetupInk.ok) + Text(text).font(.system(size: 12.5)) + Spacer(minLength: 4) + Button("Sign out", action: signOut) + } + } +} + +enum NodSetupInk { + static let ok = Color(red: 0.494, green: 0.894, blue: 0.608) + static let warning = Color(red: 1.0, green: 0.804, blue: 0.478) +} diff --git a/graphcode/Sources/Features/Settings/SettingsView.swift b/graphcode/Sources/Features/Settings/SettingsView.swift index 1c0e28b0..f3d30f27 100644 --- a/graphcode/Sources/Features/Settings/SettingsView.swift +++ b/graphcode/Sources/Features/Settings/SettingsView.swift @@ -11,19 +11,48 @@ import SwiftUI struct SettingsView: View { @Bindable private var model = SettingsModel.shared - /// One pane, no tabs. There was an Appearance tab, and it held exactly one control — a - /// window-opacity slider applied as `NSWindow.alphaValue`, which faded the terminal's - /// text along with everything else. Ghostty's own `background-opacity` does the job - /// properly (background only, text left crisp) and a terminal's config is where people - /// look for it, so the setting went rather than being reimplemented here. A tab holding - /// nothing is worse than no tab. + @State private var pane: SettingsPane? = .general + @State private var nodSetup = NodSetupModel() + @State private var mcpServers: [NodMCPServer] = [.graphcode] + + /// A sidebar of General, Agents and Templates. Agents lists Nod and each CLI, because + /// what an agent may do without asking is set per agent, and Nod has a page of its own. var body: some View { - sessions - // Resizable both ways: 560 is the width the window opens at, and the floor - // sits at 480 — the grouped form's captions wrap, so narrow just means taller. - .frame(minWidth: 480, idealWidth: 560, maxWidth: .infinity) - .padding(.vertical, 4) - .background(SettingsWindowResizability()) + NavigationSplitView { + List(selection: $pane) { + Label("General", systemImage: "gearshape").tag(SettingsPane.general) + Section("Agents") { + ForEach(CLISessionBackendKind.agentsOffered(), id: \.self) { backend in + Text(backend.displayName).tag(SettingsPane.agent(backend)) + } + } + Label("Templates", systemImage: "doc.on.doc").tag(SettingsPane.templates) + } + .navigationSplitViewColumnWidth(min: 150, ideal: 170, max: 220) + } detail: { + detail + .padding(.vertical, 4) + } + .frame(minWidth: 640, idealWidth: 760, maxWidth: .infinity, minHeight: 520) + .background(SettingsWindowResizability()) + .onAppear { + nodSetup.refreshSignIn() + mcpServers = NodMCPServer.load( + projects: ProjectPersistence(baseDirectory: SupportDirectory.url).loadRecentProjects()) + } + } + + @ViewBuilder private var detail: some View { + switch pane ?? .general { + case .general: + sessions + case .agent(.nod): + NodSettingsPane(settings: $model.settings.nod, setup: nodSetup, mcpServers: mcpServers) + case .agent(let backend): + CLIAgentSettingsPane(backend: backend, settings: $model.settings) + case .templates: + Form { TemplatesSettingsSection() }.formStyle(.grouped) + } } private var sessions: some View { @@ -45,70 +74,6 @@ struct SettingsView: View { .foregroundStyle(.secondary) } - Section { - Picker("Claude Code", selection: $model.settings.claudePermissionMode) { - ForEach(GraphcodeSettings.ClaudePermissionMode.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } - } - Text(model.settings.claudePermissionMode.explanation) - .font(.caption2) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Picker("Copilot CLI", selection: $model.settings.copilotPermissions) { - ForEach(GraphcodeSettings.CopilotPermissions.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } - } - Text(model.settings.copilotPermissions.explanation) - .font(.caption2) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Picker("Codex", selection: $model.settings.codexApprovals) { - ForEach(GraphcodeSettings.CodexApprovals.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } - } - Text(model.settings.codexApprovals.explanation) - .font(.caption2) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Picker("OpenCode", selection: $model.settings.openCodePermissions) { - ForEach(GraphcodeSettings.OpenCodePermissions.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } - } - Text(model.settings.openCodePermissions.explanation) - .font(.caption2) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - - Picker("Pi", selection: $model.settings.piProjectTrust) { - ForEach(GraphcodeSettings.PiProjectTrust.allCases, id: \.self) { mode in - Text(mode.displayName).tag(mode) - } - } - Text(model.settings.piProjectTrust.explanation) - .font(.caption2) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } header: { - Text("Permissions") - } footer: { - Text( - "A loop runs whether or not this window is open, so nobody is there to answer a " - + "permission prompt. A backend left on its own default waits at that prompt " - + "while the graph reports the loop as running." - ) - .font(.caption2) - .foregroundStyle(.secondary) - } - - PreferredVersionsSettingsSection(settings: $model.settings) - Section { Picker("Default model", selection: $model.settings.defaultModelTier) { ForEach(ModelTier.allCases, id: \.self) { tier in @@ -269,7 +234,6 @@ struct SettingsView: View { .foregroundStyle(.secondary) } - TemplatesSettingsSection() } .formStyle(.grouped) } diff --git a/graphcode/Sources/Features/Welcome/OnboardingPages.swift b/graphcode/Sources/Features/Welcome/OnboardingPages.swift index ee2dea94..085e6d42 100644 --- a/graphcode/Sources/Features/Welcome/OnboardingPages.swift +++ b/graphcode/Sources/Features/Welcome/OnboardingPages.swift @@ -247,6 +247,7 @@ struct OnboardingLoopTypesPage: View { /// first watcher refuses to be created rather than after. struct OnboardingBackendPage: View { @Binding var selection: CLISessionBackendKind + @State private var nodSetup: NodSetupModel? var body: some View { VStack(spacing: 14) { @@ -260,7 +261,10 @@ struct OnboardingBackendPage: View { .frame(maxWidth: 470) VStack(spacing: 8) { - ForEach(CLISessionBackendKind.allCases, id: \.self) { backend in + ForEach( + CLISessionBackendKind.allCases.filter { $0 != .nod || FeatureRamps.isEnabled(.nod) }, + id: \.self + ) { backend in row(backend) } } @@ -269,20 +273,31 @@ struct OnboardingBackendPage: View { // No PATH probing and no version strings: nothing in `BackendCapabilities` looks, // so claiming to have found something would be the tour making it up. Text( - "The CLI must be installed and on your PATH — GraphCode launches it, it doesn't " - + "bundle it." + "A CLI must be installed and on your PATH — GraphCode launches it, it doesn't " + + "bundle it. Nod needs only a sign-in." ) .font(.system(size: 11)) .foregroundStyle(.white.opacity(0.45)) .multilineTextAlignment(.center) .frame(maxWidth: 440) } + .sheet(item: $nodSetup) { model in + NodSetupView(model: model) { nodSetup = nil } + .preferredColorScheme(.dark) + } } + /// Nod needs an engine and a sign-in, not a binary on PATH, so its row opens setup. + /// It becomes the default only once GraphCode can launch it (`isSpiked`). private func row(_ backend: CLISessionBackendKind) -> some View { let isSelected = selection == backend return Button { - selection = backend + if backend == .nod { + nodSetup = NodSetupModel() + if backend.isSpiked { selection = backend } + } else { + selection = backend + } } label: { HStack(spacing: 13) { Text(">_") @@ -300,8 +315,14 @@ struct OnboardingBackendPage: View { .foregroundStyle(.white.opacity(0.55)) } Spacer(minLength: 0) - Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") - .foregroundStyle(isSelected ? Theme.paneFocusTint : .white.opacity(0.25)) + if backend == .nod && !isSelected { + Text("Set up") + .font(.system(size: 11.5, weight: .semibold)) + .foregroundStyle(Theme.paneFocusTint) + } else { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .foregroundStyle(isSelected ? Theme.paneFocusTint : .white.opacity(0.25)) + } } .padding(.vertical, 13) .padding(.horizontal, 15) diff --git a/graphcode/Tests/NodSetupRenderTests.swift b/graphcode/Tests/NodSetupRenderTests.swift new file mode 100644 index 00000000..038b0e09 --- /dev/null +++ b/graphcode/Tests/NodSetupRenderTests.swift @@ -0,0 +1,238 @@ +import AppKit +import GraphcodeKit +import SwiftUI +import Testing + +@testable import graphcode + +/// Draws Nod's setup, settings, agent menu and cards to PNGs under `.build/nod-renders` +/// for review, through `NSHostingView` because `ImageRenderer` blanks scrolling content. +/// Each render also has to come out non-blank, so a view that lays out to nothing fails. +@MainActor +@Suite struct NodSetupRenderTests { + static let directory = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent().deletingLastPathComponent().deletingLastPathComponent() + .appending(path: ".build/nod-renders") + + final class Box: @unchecked Sendable { + var settings = NodSettings(shellAllowlist: ["swift test *", "make lint", "git status|diff|log"]) + } + + static func setupModel( + engine: NodEngine, phase: NodSetupModel.CopilotPhase = .idle, + credentials: NodCredentialStore = .inMemory() + ) -> NodSetupModel { + let box = Box() + box.settings.engine = engine + let model = NodSetupModel( + credentials: credentials, + deviceFlow: CopilotDeviceFlow(clientID: nil, transport: { _ in (Data(), 500) }), + readSettings: { box.settings }, writeSettings: { box.settings = $0 }, openURL: { _ in }, + claudeCodeSignInFound: { false }) + model.copilotPhase = phase + return model + } + + @discardableResult + static func render(_ view: some View, _ name: String, size: CGSize) throws -> URL { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + let host = NSHostingView( + rootView: view.frame(width: size.width, height: size.height) + .background(Color(red: 0.11, green: 0.11, blue: 0.12)) + .environment(\.colorScheme, .dark)) + host.appearance = NSAppearance(named: .darkAqua) + host.frame = CGRect(origin: .zero, size: size) + host.layoutSubtreeIfNeeded() + let rep = try #require(host.bitmapImageRepForCachingDisplay(in: host.bounds)) + host.cacheDisplay(in: host.bounds, to: rep) + let url = directory.appending(path: "\(name).png") + try #require(rep.representation(using: .png, properties: [:])).write(to: url) + #expect(distinctColours(rep) > 3, "\(name) rendered blank") + return url + } + + static func distinctColours(_ rep: NSBitmapImageRep) -> Int { + var seen = Set() + for x in stride(from: 0, to: rep.pixelsWide, by: 7) { + for y in stride(from: 0, to: rep.pixelsHigh, by: 7) { + guard let c = rep.colorAt(x: x, y: y)?.usingColorSpace(.deviceRGB) else { continue } + seen.insert( + UInt32(c.redComponent * 255) << 16 | UInt32(c.greenComponent * 255) << 8 + | UInt32(c.blueComponent * 255)) + if seen.count > 50 { return seen.count } + } + } + return seen.count + } + + @Test func setupSheets() throws { + let claude = Self.setupModel(engine: .claudeAgentSDK) + claude.showsAPIKeyField = true + try Self.render( + NodSetupView(model: claude), "5a-setup-claude", size: .init(width: 520, height: 470)) + + let code = CopilotDeviceFlow.DeviceCode( + deviceCode: "d", userCode: "8F2K-QW7D", + verificationURL: URL(string: "https://github.com/login/device")!, + expiresAt: Date().addingTimeInterval(852), interval: .seconds(5)) + try Self.render( + NodSetupView(model: Self.setupModel(engine: .copilotSDK, phase: .waiting(code))), + "5b-copilot-waiting", size: .init(width: 520, height: 500)) + + let account = CopilotDeviceFlow.Account( + login: "scgopi", plan: "business", modelCount: 7, premiumRequestsUsed: 212, + premiumRequestsLimit: 300) + try Self.render( + NodSetupView( + model: Self.setupModel( + engine: .copilotSDK, phase: .signedIn(account), + credentials: .inMemory([.githubCopilot: "gho_x"]))), + "5b-copilot-signed-in", size: .init(width: 520, height: 450)) + } + + @Test func settingsPanes() throws { + let setup = Self.setupModel( + engine: .claudeAgentSDK, + credentials: .inMemory([.anthropicAPIKey: "sk-ant-x", .githubCopilot: "gho_x"])) + let box = Box() + let servers: [NodMCPServer] = [ + .graphcode, + NodMCPServer(name: "github", source: .project("graphcode"), needsSignIn: false), + NodMCPServer(name: "sentry", source: .project("graphcode"), needsSignIn: true), + ] + let pane = NodSettingsPane( + settings: Binding(get: { box.settings }, set: { box.settings = $0 }), setup: setup, + mcpServers: servers) + try Self.render( + HStack(spacing: 0) { + SettingsSidebarMock(selected: .agent(.nod)).frame(width: 170) + Divider() + pane + }, + "7a-settings-nod", size: .init(width: 780, height: 1500)) + + var general = GraphcodeSettings() + try Self.render( + HStack(spacing: 0) { + SettingsSidebarMock(selected: .agent(.copilotCLI)).frame(width: 170) + Divider() + CLIAgentSettingsPane( + backend: .copilotCLI, + settings: Binding(get: { general }, set: { general = $0 })) + }, + "7a-settings-copilot-cli", size: .init(width: 780, height: 420)) + } + + @Test func agentMenu() throws { + try Self.render( + AgentMenuMock(loopType: .composite), "6a-agent-menu-composite", + size: .init(width: 300, height: 300)) + try Self.render( + AgentMenuMock(loopType: .goalBased), "6a-agent-menu-goal", + size: .init(width: 300, height: 300)) + } + + @Test func canvasCards() throws { + let now = Date() + let codex = LoopNode( + title: "Billing UI", loopType: .turnBased, checkDescription: "diff reads clean", + backend: .codex, activity: "working…", state: .running) + let goal = LoopNode( + title: "Monetization", loopType: .goalBased, + goal: GoalSpec(summary: "every paid route enforces the cap"), backend: .nod, + activity: "Running swift test · turn 4", state: .running) + let ask = LoopNode( + title: "Cap research", loopType: .sketch, backend: .nod, + activity: "asks to run swift package resolve", state: .awaitingInput) + let allowlisted = LoopNode( + title: "Lint sweep", loopType: .sketch, backend: .nod, + activity: "asks to run make lint", state: .awaitingInput) + try Self.render( + VStack(spacing: 14) { + LoopCardView(node: codex, reason: nil, now: now, onPrimaryAction: {}) + LoopCardView( + node: goal, reason: nil, now: now, onPrimaryAction: {}, + nod: NodCardDetail(goalMet: 1, goalTotal: 2)) + LoopCardView( + node: ask, reason: .awaitingInput, now: now, onPrimaryAction: {}, + nod: NodCardDetail( + ask: .init( + askID: "a", kind: .network, subject: "swift package resolve", + answerableFromCard: false))) + LoopCardView( + node: allowlisted, reason: .awaitingInput, now: now, onPrimaryAction: {}, + nod: NodCardDetail( + ask: .init(askID: "b", kind: .shell, subject: "make lint", answerableFromCard: true))) + } + .padding(20), + "6b-canvas-cards", size: .init(width: 300, height: 480)) + } +} + +/// The settings sidebar as drawn, for the render only: `NavigationSplitView` draws nothing +/// in an offscreen host. +private struct SettingsSidebarMock: View { + let selected: SettingsPane + + var body: some View { + VStack(alignment: .leading, spacing: 2) { + row("General", .general) + Text("Agents").font(.caption).foregroundStyle(.secondary).padding(.top, 8).padding( + .leading, 8) + ForEach(CLISessionBackendKind.settingsOrder, id: \.self) { row($0.displayName, .agent($0)) } + row("Templates", .templates).padding(.top, 8) + Spacer() + } + .padding(8) + } + + private func row(_ title: String, _ pane: SettingsPane) -> some View { + Text(title) + .padding(.vertical, 4) + .padding(.horizontal, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + pane == selected ? Color.accentColor.opacity(0.35) : .clear, + in: RoundedRectangle(cornerRadius: 5)) + } +} + +/// The open agent menu, drawn from the same `AgentMenuSection` values the real menu uses. +private struct AgentMenuMock: View { + let loopType: LoopType + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + Text( + "Agent: \(AgentMenuSection.label(backend: .nod, tier: nil, loopType: loopType, nod: NodSettings())) ▾" + ) + .font(.system(size: 12, weight: .semibold)) + .padding(.bottom, 6) + ForEach(AgentMenuSection.sections(for: loopType), id: \.surface) { section in + Text(section.title).font(.system(size: 10.5, weight: .bold)).foregroundStyle(.secondary) + .padding(.top, 4) + ForEach(section.entries, id: \.backend) { entry in + HStack { + Text(entry.label) + if entry.backend == .nod { Text("Claude Agent SDK ✓").foregroundStyle(.secondary) } + Spacer() + if let note = entry.note { Text(note).foregroundStyle(.secondary) } + } + .font(.system(size: 12)) + .opacity(entry.isEnabled ? 1 : 0.4) + if entry.backend == .nod { + HStack { + Text("Model") + Spacer() + Text("\(NodSettings().resolvedModel(for: loopType).displayName) ▸") + } + .font(.system(size: 12)) + .opacity(entry.isEnabled ? 1 : 0.4) + } + } + } + Spacer() + } + .padding(12) + } +} diff --git a/graphcode/Tests/NodSetupTests.swift b/graphcode/Tests/NodSetupTests.swift new file mode 100644 index 00000000..583a44f8 --- /dev/null +++ b/graphcode/Tests/NodSetupTests.swift @@ -0,0 +1,537 @@ +import Foundation +import GraphcodeKit +import Testing + +@testable import graphcode + +@Suite struct NodModelCatalogTests { + @Test func everyEngineOffersEveryTier() { + for engine in NodEngine.allCases { + for tier in ModelTier.allCases { + #expect(NodModelCatalog.model(for: tier, engine: engine).tier == tier) + } + } + } + + @Test func defaultsFollowTheDesignPerLoopType() { + let settings = NodSettings() + #expect(settings.resolvedModel(for: .sketch).displayName == "Sonnet") + #expect(settings.resolvedModel(for: .goalBased).displayName == "Opus") + #expect(settings.resolvedModel(for: .timeBased).displayName == "Haiku") + #expect(settings.resolvedModel(for: .turnBased).displayName == "Sonnet") + #expect(settings.resolvedModel(for: .composite).displayName == "Opus") + #expect(settings.resolvedCompositeChildModel.displayName == "Sonnet") + #expect(settings.resolvedGoalEvaluatorModel.displayName == "Haiku") + } + + @Test func anExplicitTierBeatsTheStoredChoice() { + var settings = NodSettings() + settings.modelsByLoopType[LoopType.goalBased.rawValue] = "haiku" + #expect(settings.resolvedModel(for: .goalBased).id == "haiku") + #expect(settings.resolvedModel(for: .goalBased, tier: .standard).id == "sonnet") + } + + @Test func aStaleStoredModelFallsBackToTheDefault() { + var settings = NodSettings() + settings.modelsByLoopType[LoopType.sketch.rawValue] = "claude-2" + settings.goalEvaluatorModel = "gone" + #expect(settings.resolvedModel(for: .sketch).id == "sonnet") + #expect(settings.resolvedGoalEvaluatorModel.id == "haiku") + } + + @Test func choosingTheDefaultClearsTheEntry() { + var settings = NodSettings() + settings.setModel(NodModelCatalog.model(for: .fast, engine: .claudeAgentSDK), for: .sketch) + #expect(settings.modelsByLoopType == ["sketch": "haiku"]) + settings.setModel(NodModelCatalog.model(for: .standard, engine: .claudeAgentSDK), for: .sketch) + #expect(settings.modelsByLoopType.isEmpty) + } + + @Test func switchingEngineDropsModelsTheNewEngineCannotRun() { + var settings = NodSettings( + modelsByLoopType: ["sketch": "haiku"], compositeChildModel: "opus", + goalEvaluatorModel: "haiku") + settings.switchEngine(to: .copilotSDK) + #expect(settings.engine == .copilotSDK) + #expect(settings.modelsByLoopType.isEmpty) + #expect(settings.compositeChildModel == nil) + #expect(settings.goalEvaluatorModel == nil) + #expect(settings.resolvedModel(for: .goalBased).family == .claude) + #expect(settings.resolvedModel(for: .timeBased).id == "gpt-5.6-luna") + } + + @Test func allowlistTrimsAndIgnoresDuplicates() { + var settings = NodSettings() + let added = settings.addAllowlistPattern(" swift test * ") + let duplicate = settings.addAllowlistPattern("swift test *") + let blank = settings.addAllowlistPattern(" ") + let second = settings.addAllowlistPattern("make lint") + #expect(added && !duplicate && !blank && second) + settings.removeAllowlistPattern("swift test *") + #expect(settings.shellAllowlist == ["make lint"]) + } + + @Test func spendCapRejectsNonsense() { + var settings = NodSettings() + settings.setSpendCap(2.499) + #expect(settings.spendCapUSD == 2.5) + settings.setSpendCap(-1) + #expect(settings.spendCapUSD == 0) + settings.setSpendCap(.infinity) + #expect(settings.spendCapUSD == 0) + } + + @Test func nodSettingsRoundTripThroughGraphcodeSettings() throws { + var settings = GraphcodeSettings() + settings.nod.switchEngine(to: .copilotSDK) + settings.nod.disabledMCPServers = ["sentry"] + settings.nod.addAllowlistPattern("make lint") + let data = try JSONEncoder().encode(settings) + let decoded = try JSONDecoder().decode(GraphcodeSettings.self, from: data) + #expect(decoded.nod == settings.nod) + } + + @Test func olderSettingsWithoutTheNewFieldStillDecode() throws { + let decoded = try JSONDecoder().decode( + NodSettings.self, from: Data(#"{"engine":"copilot","spendCapUSD":5}"#.utf8)) + #expect(decoded.engine == .copilotSDK) + #expect(decoded.spendCapUSD == 5) + #expect(decoded.disabledMCPServers.isEmpty) + } +} + +@Suite struct NodCredentialStoreTests { + @Test func inMemoryStoreTracksSignInPerEngine() throws { + let store = NodCredentialStore.inMemory() + #expect(!store.isSignedIn(.claudeAgentSDK)) + try store.write("sk-ant-api03-0123456789abcdef", .anthropicAPIKey) + #expect(store.isSignedIn(.claudeAgentSDK)) + #expect(!store.isSignedIn(.copilotSDK)) + try store.write("gho_token", .githubCopilot) + #expect(store.isSignedIn(.copilotSDK)) + try store.signOut(.claudeAgentSDK) + #expect(!store.isSignedIn(.claudeAgentSDK)) + #expect(store.isSignedIn(.copilotSDK)) + } + + @Test func anEmptySecretIsNotASignIn() throws { + let store = NodCredentialStore.inMemory([.githubCopilot: ""]) + #expect(!store.isSignedIn(.copilotSDK)) + } + + @Test func liveKeychainWritesReadsOverwritesAndDeletes() throws { + let store = NodCredentialStore.keychain(service: "app.graphcode.nod.tests.\(UUID())") + defer { try? store.delete(.anthropicAPIKey) } + #expect(try store.read(.anthropicAPIKey) == nil) + try store.write("sk-ant-first", .anthropicAPIKey) + #expect(try store.read(.anthropicAPIKey) == "sk-ant-first") + try store.write("sk-ant-second", .anthropicAPIKey) + #expect(try store.read(.anthropicAPIKey) == "sk-ant-second") + try store.delete(.anthropicAPIKey) + #expect(try store.read(.anthropicAPIKey) == nil) + try store.delete(.anthropicAPIKey) + } + + @Test func liveStoreUsesNodsKeychainService() { + #expect(NodSettings.keychainService == "app.graphcode.nod") + } +} + +@Suite struct NodClaudeSignInTests { + @Test func subscriptionLoginStaysOffUntilAnthropicApprovesIt() { + #expect(!NodClaudeSignIn.subscriptionLoginAllowed) + } + + @Test func apiKeyIsTrimmedAndChecked() { + #expect( + NodClaudeSignIn.validateAPIKey(" sk-ant-api03-abcdefghijklmnop\n") + == .success("sk-ant-api03-abcdefghijklmnop")) + #expect(NodClaudeSignIn.validateAPIKey(" ") == .failure(.empty)) + #expect( + NodClaudeSignIn.validateAPIKey("gho_abcdefghijklmnopqrstuvwxyz") + == .failure(.notAnAnthropicKey)) + #expect(NodClaudeSignIn.validateAPIKey("sk-ant-short") == .failure(.notAnAnthropicKey)) + } + + @Test func claudeCodeSignInIsFoundByKeychainItemOrFile() throws { + let home = FileManager.default.temporaryDirectory.appending(path: "nod-home-\(UUID())") + defer { try? FileManager.default.removeItem(at: home) } + #expect(!NodClaudeSignIn.claudeCodeSignInFound(home: home) { _ in false }) + #expect(NodClaudeSignIn.claudeCodeSignInFound(home: home) { $0 == "Claude Code-credentials" }) + try FileManager.default.createDirectory( + at: home.appending(path: ".claude"), withIntermediateDirectories: true) + try Data("{}".utf8).write(to: home.appending(path: ".claude/.credentials.json")) + #expect(NodClaudeSignIn.claudeCodeSignInFound(home: home) { _ in false }) + } +} + +@Suite struct CopilotDeviceFlowTests { + final class Script: @unchecked Sendable { + var replies: [(String, Int, String)] + var requests: [URLRequest] = [] + var sleeps: [Duration] = [] + let lock = NSLock() + + init(_ replies: [(String, Int, String)]) { self.replies = replies } + + func reply(to request: URLRequest) -> (Data, Int) { + lock.withLock { + requests.append(request) + guard + let index = replies.firstIndex(where: { + request.url!.absoluteString.hasSuffix($0.0) + }) + else { return (Data(), 404) } + let reply = replies.remove(at: index) + return (Data(reply.2.utf8), reply.1) + } + } + + func flow(clientID: String? = "Iv1.test", now: Date = Date(timeIntervalSince1970: 0)) + -> CopilotDeviceFlow + { + CopilotDeviceFlow( + clientID: clientID, + transport: { request in + self.reply(to: request) + }, + sleep: { duration in + self.lock.withLock { self.sleeps.append(duration) } + }, + now: { now }) + } + } + + static let code = CopilotDeviceFlow.DeviceCode( + deviceCode: "dev", userCode: "8F2K-QW7D", + verificationURL: URL(string: "https://github.com/login/device")!, + expiresAt: Date(timeIntervalSince1970: 900), interval: .seconds(5)) + + @Test func requestsACodeWithTheClientIDAndScope() async throws { + let script = Script([ + ( + "/login/device/code", 200, + #"{"device_code":"dev","user_code":"8F2K-QW7D","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"# + ) + ]) + let code = try await script.flow().requestCode() + #expect(code == Self.code) + let body = String(data: script.requests[0].httpBody!, encoding: .utf8) + #expect(body == "client_id=Iv1.test&scope=read:user") + #expect(script.requests[0].value(forHTTPHeaderField: "Accept") == "application/json") + } + + @Test func withoutAClientIDItSaysSoInsteadOfCallingGitHub() async { + let script = Script([]) + await #expect(throws: CopilotDeviceFlow.Failure.notConfigured) { + try await script.flow(clientID: nil).requestCode() + } + #expect(script.requests.isEmpty) + } + + @Test func pollsThroughPendingAndSlowDownToAToken() async throws { + let script = Script([ + ("/login/oauth/access_token", 200, #"{"error":"authorization_pending"}"#), + ("/login/oauth/access_token", 200, #"{"error":"slow_down","interval":10}"#), + ("/login/oauth/access_token", 200, #"{"access_token":"gho_abc","token_type":"bearer"}"#), + ]) + let token = try await script.flow().pollForToken(Self.code) + #expect(token == "gho_abc") + #expect(script.sleeps == [.seconds(5), .seconds(5), .seconds(10)]) + let body = String(data: script.requests[0].httpBody!, encoding: .utf8)! + #expect(body.contains("grant_type=urn:ietf:params:oauth:grant-type:device_code")) + } + + @Test func expiryAndDenialEndThePoll() async { + let expired = Script([("/login/oauth/access_token", 200, #"{"error":"expired_token"}"#)]) + await #expect(throws: CopilotDeviceFlow.Failure.expired) { + try await expired.flow().pollForToken(Self.code) + } + let denied = Script([("/login/oauth/access_token", 200, #"{"error":"access_denied"}"#)]) + await #expect(throws: CopilotDeviceFlow.Failure.denied) { + try await denied.flow().pollForToken(Self.code) + } + let late = Script([]) + await #expect(throws: CopilotDeviceFlow.Failure.expired) { + try await late.flow(now: Date(timeIntervalSince1970: 901)).pollForToken(Self.code) + } + #expect(late.requests.isEmpty) + } + + @Test func readsTheAccountPlanPremiumRequestsAndModels() async throws { + let script = Script([ + ("api.github.com/user", 200, #"{"login":"scgopi"}"#), + ( + "/copilot_internal/user", 200, + #"{"copilot_plan":"business","quota_snapshots":{"premium_interactions":{"entitlement":300,"remaining":88,"unlimited":false}}}"# + ), + ("/copilot_internal/v2/token", 200, #"{"token":"tid=abc"}"#), + ( + "/models", 200, + #"{"data":[{"id":"a","model_picker_enabled":true},{"id":"b"},{"id":"c","model_picker_enabled":false}]}"# + ), + ]) + let account = try await script.flow().account(token: "gho_abc") + #expect( + account + == .init( + login: "scgopi", plan: "business", modelCount: 2, premiumRequestsUsed: 212, + premiumRequestsLimit: 300)) + #expect(account.planName == "Copilot Business") + #expect(script.requests[0].value(forHTTPHeaderField: "Authorization") == "token gho_abc") + #expect(script.requests.last?.value(forHTTPHeaderField: "Authorization") == "Bearer tid=abc") + } + + @Test func aMissingPlanStillSignsIn() async throws { + let script = Script([("api.github.com/user", 200, #"{"login":"scgopi"}"#)]) + let account = try await script.flow().account(token: "gho_abc") + #expect(account == .init(login: "scgopi")) + #expect(CopilotSignInText.accountDetail(account) == "") + } + + @Test func signInText() { + let now = Date(timeIntervalSince1970: 0) + #expect( + CopilotSignInText.countdown(until: Date(timeIntervalSince1970: 852), now: now) == "14:12") + #expect(CopilotSignInText.countdown(until: Date(timeIntervalSince1970: -5), now: now) == "0:00") + #expect( + CopilotSignInText.accountDetail( + .init( + login: "scgopi", plan: "business", modelCount: 7, premiumRequestsUsed: 212, + premiumRequestsLimit: 300)) + == "7 models available · premium requests 212 / 300 this month") + } +} + +@Suite struct AgentMenuSectionTests { + @Test func groupsChatThenTerminal() { + let sections = AgentMenuSection.sections(for: .sketch) + #expect(sections.map(\.title) == ["Chat", "Terminal"]) + #expect(sections[0].entries.map(\.backend) == [.nod]) + #expect( + sections[1].entries.map(\.backend) == [.claudeCode, .codex, .copilotCLI, .openCode, .pi]) + } + + @Test func withNodsRampOffOnlyTerminalIsOffered() { + let sections = AgentMenuSection.sections(for: .sketch, nodEnabled: false) + #expect(sections.map(\.title) == ["Terminal"]) + #expect(!CLISessionBackendKind.agentsOffered(nodEnabled: false).contains(.nod)) + #expect(CLISessionBackendKind.agentsOffered(nodEnabled: true).first == .nod) + } + + @Test func nodIsHeldToTheBetaRamp() { + #expect(FeatureRamps.Feature.nod.defaultPercents == ["beta": 100, "stable": 0]) + } + + @Test func greyingFollowsCanHost() { + for loopType in LoopType.allCases { + for entry in AgentMenuSection.sections(for: loopType).flatMap(\.entries) { + #expect(entry.isEnabled == entry.backend.canHost(loopType)) + #expect((entry.note == nil) == entry.isEnabled) + } + } + } + + @Test func piIsGreyedForACompositeWithTheReason() { + let pi = AgentMenuSection.sections(for: .composite)[1].entries.first { $0.backend == .pi } + #expect(pi?.isEnabled == CLISessionBackendKind.pi.canHost(.composite)) + if pi?.isEnabled == false { #expect(pi?.note == "no sub-agents") } + } + + @Test func nodWaitsForItsRuntime() { + let nod = AgentMenuSection.sections(for: .sketch)[0].entries[0] + #expect(nod.isEnabled == CLISessionBackendKind.nod.isSpiked) + if !nod.isEnabled { #expect(nod.note == "not available yet") } + } + + @Test func labelNamesNodsModel() { + let nod = NodSettings() + #expect( + AgentMenuSection.label(backend: .nod, tier: nil, loopType: .goalBased, nod: nod) + == "Nod · Opus") + #expect( + AgentMenuSection.label(backend: .nod, tier: .fast, loopType: .goalBased, nod: nod) + == "Nod · Haiku") + #expect( + AgentMenuSection.label(backend: .codex, tier: .fast, loopType: .goalBased, nod: nod) + == "Codex") + } +} + +@Suite struct NodCardPresentationTests { + static func nodNode(state: LoopState = .running, activity: String? = nil) -> LoopNode { + LoopNode( + title: "Monetization", loopType: .goalBased, + goal: GoalSpec(summary: "every paid route enforces the cap"), backend: .nod, + activity: activity, state: state) + } + + @Test func goalClausesBecomeAProgressBar() { + let card = LoopCardPresentation( + node: Self.nodNode(activity: "Running swift test · turn 4"), + nod: NodCardDetail(goalMet: 1, goalTotal: 2)) + #expect(card.liveLine == "Running swift test · turn 4") + #expect(card.detail == .progress(.init(fraction: 0.5, readings: "goal 1 / 2", change: ""))) + #expect(card.showsNodGlyph) + #expect(card.meta.contains("Nod")) + } + + @Test func allClausesMetFillsTheBar() { + let card = LoopCardPresentation( + node: Self.nodNode(), nod: NodCardDetail(goalMet: 3, goalTotal: 3)) + #expect(card.detail == .progress(.init(fraction: 1, readings: "goal 3 / 3", change: "all met"))) + } + + @Test func aPendingAskOutranksProgressAndTheGenericReply() { + let ask = NodCardDetail.Ask( + askID: "a1", kind: .shell, subject: "swift test", answerableFromCard: true) + let card = LoopCardPresentation( + node: Self.nodNode(state: .awaitingInput, activity: "asks to run swift test"), + nod: NodCardDetail(goalMet: 1, goalTotal: 2, ask: ask)) + #expect(card.detail == .nodAsk(ask)) + #expect(card.liveLine == "asks to run swift test") + } + + @Test func withoutNodStateANodCardDrawsLikeAnyOther() { + let card = LoopCardPresentation(node: Self.nodNode()) + #expect(card.detail == .none) + #expect(card.showsNodGlyph) + } + + @Test func cliCardsIgnoreNodDetailAndKeepTheBadgeRule() { + let codex = LoopNode(title: "Billing UI", loopType: .turnBased, backend: .codex) + let card = LoopCardPresentation(node: codex, nod: NodCardDetail(goalMet: 1, goalTotal: 2)) + #expect(card.detail == .none) + #expect(!card.showsNodGlyph) + #expect(card.meta.contains("Codex")) + let claude = LoopCardPresentation(node: LoopNode(title: "x", loopType: .sketch)) + #expect(!claude.meta.contains("Claude Code")) + } + + @Test func zeroClausesDrawNoBar() { + let card = LoopCardPresentation( + node: Self.nodNode(), nod: NodCardDetail(goalMet: 0, goalTotal: 0)) + #expect(card.detail == .none) + } +} + +@Suite struct NodMCPServerTests { + @Test func parsesLocalAndRemoteServers() { + let json = #""" + {"mcpServers":{ + "github":{"type":"http","url":"https://api.githubcopilot.com/mcp","headers":{"Authorization":"Bearer x"}}, + "sentry":{"type":"http","url":"https://mcp.sentry.dev/mcp"}, + "legacy":{"url":"https://example.com/sse"}, + "files":{"command":"npx","args":["fs"]}, + "graphcode":{"command":"graphcode"} + }} + """# + let servers = NodMCPServer.parse(Data(json.utf8), projectName: "graphcode") + #expect(servers.map(\.name) == ["files", "github", "legacy", "sentry"]) + #expect(servers.filter(\.needsSignIn).map(\.name) == ["legacy", "sentry"]) + #expect(servers.allSatisfy { $0.source == .project("graphcode") }) + } + + @Test func brokenJSONYieldsNothing() { + #expect(NodMCPServer.parse(Data("{".utf8), projectName: "p").isEmpty) + #expect(NodMCPServer.parse(Data("[]".utf8), projectName: "p").isEmpty) + } + + @Test func loadsBuiltInFirstAndFirstProjectWinsOnAClash() throws { + let root = FileManager.default.temporaryDirectory.appending(path: "nod-mcp-\(UUID())") + defer { try? FileManager.default.removeItem(at: root) } + for (name, body) in [ + ("a", #"{"mcpServers":{"github":{"command":"gh"}}}"#), + ("b", #"{"mcpServers":{"github":{"url":"https://x"},"sentry":{"url":"https://s"}}}"#), + ] { + let dir = root.appending(path: name) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + try Data(body.utf8).write(to: dir.appending(path: ".mcp.json")) + } + let servers = NodMCPServer.load(projects: [ + ProjectRef(path: root.appending(path: "a").path, name: "a"), + ProjectRef(path: root.appending(path: "b").path, name: "b"), + ProjectRef(path: "ssh://host/repo", name: "remote"), + ]) + #expect(servers.map(\.name) == ["graphcode", "github", "sentry"]) + #expect(servers[1].source == .project("a")) + #expect(!servers[1].needsSignIn) + } +} + +@MainActor +@Suite struct NodSetupModelTests { + final class Box: @unchecked Sendable { + var settings = NodSettings() + var opened: [URL] = [] + } + + static func model( + _ box: Box, credentials: NodCredentialStore = .inMemory(), + flow: CopilotDeviceFlow = CopilotDeviceFlow(clientID: nil, transport: { _ in (Data(), 500) }) + ) -> NodSetupModel { + NodSetupModel( + credentials: credentials, deviceFlow: flow, + readSettings: { box.settings }, writeSettings: { box.settings = $0 }, + openURL: { box.opened.append($0) }, claudeCodeSignInFound: { true }) + } + + @Test func savingAValidKeySignsClaudeIn() throws { + let box = Box() + let store = NodCredentialStore.inMemory() + let model = Self.model(box, credentials: store) + #expect(!model.isSignedIn(.claudeAgentSDK)) + model.apiKeyDraft = "gho_wrongfield_abcdefghijk" + model.saveAPIKey() + #expect(model.apiKeyError != nil) + #expect(!model.isSignedIn(.claudeAgentSDK)) + model.apiKeyDraft = " sk-ant-api03-abcdefghijklmnop " + model.saveAPIKey() + #expect(model.apiKeyError == nil) + #expect(model.apiKeyDraft.isEmpty) + #expect(model.isSignedIn(.claudeAgentSDK)) + #expect(try store.read(.anthropicAPIKey) == "sk-ant-api03-abcdefghijklmnop") + } + + @Test func claudeCodeSignInIsNotOfferedWhileGated() { + #expect(!Self.model(Box()).claudeCodeSignInFound) + } + + @Test func selectingAnEngineWritesSettings() { + let box = Box() + let model = Self.model(box) + model.selectEngine(.copilotSDK) + #expect(box.settings.engine == .copilotSDK) + #expect(model.settings.engine == .copilotSDK) + } + + @Test func copilotDeviceFlowEndsSignedInWithTheTokenInTheKeychain() async throws { + let box = Box() + let store = NodCredentialStore.inMemory() + let script = CopilotDeviceFlowTests.Script([ + ( + "/login/device/code", 200, + #"{"device_code":"dev","user_code":"8F2K-QW7D","verification_uri":"https://github.com/login/device","expires_in":900,"interval":5}"# + ), + ("/login/oauth/access_token", 200, #"{"access_token":"gho_abc"}"#), + ("api.github.com/user", 200, #"{"login":"scgopi"}"#), + ]) + let model = Self.model(box, credentials: store, flow: script.flow(now: Date())) + model.startCopilotSignIn() + await model.waitForCopilotSignIn() + #expect(model.copilotPhase == .signedIn(.init(login: "scgopi"))) + #expect(try store.read(.githubCopilot) == "gho_abc") + #expect(model.isSignedIn(.copilotSDK)) + #expect(box.opened == [URL(string: "https://github.com/login/device")!]) + model.signOut(.copilotSDK) + #expect(model.copilotPhase == .idle) + #expect(!model.isSignedIn(.copilotSDK)) + } + + @Test func anUnconfiguredBuildSaysSo() async { + let model = Self.model(Box()) + model.startCopilotSignIn() + await model.waitForCopilotSignIn() + #expect(model.copilotPhase == .failed("This build has no GitHub sign-in configured.")) + } +}