diff --git a/CodegiOS/DesignSystem/AgentIcon.swift b/CodegiOS/DesignSystem/AgentIcon.swift index c791478..d9c9474 100644 --- a/CodegiOS/DesignSystem/AgentIcon.swift +++ b/CodegiOS/DesignSystem/AgentIcon.swift @@ -13,24 +13,42 @@ struct AgentIcon: View { let agent: AgentType /// Tint applied to monochrome (template) agents. Color agents ignore it. var tint: Color + /// Remote mark from the server (`icon_url` on a custom agent). Used when + /// present so extra accounts do not share Claude's icon. + var remoteURL: URL? - init(agent: AgentType, tint: Color? = nil) { + init(agent: AgentType, tint: Color? = nil, remoteURL: URL? = nil) { self.agent = agent self.tint = tint ?? agent.accent + self.remoteURL = remoteURL } var body: some View { + if let remoteURL { + AsyncImage(url: remoteURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFit() + default: + localMark + } + } + } else { + localMark + } + } + + @ViewBuilder + private var localMark: some View { if UIImage(named: agent.iconAsset) != nil { Image(agent.iconAsset) - // Be explicit about intent rather than relying on the asset's - // configured rendering intent: mono agents tint, color render as-is. .renderingMode(agent.iconIsTemplate ? .template : .original) .resizable() .scaledToFit() - // A template image adopts this; an original (color) image ignores it. .foregroundStyle(tint) } else { - // Defensive fallback if the brand asset is ever missing/renamed. Image(systemName: agent.symbolName) .resizable() .scaledToFit() diff --git a/CodegiOS/DesignSystem/Badges.swift b/CodegiOS/DesignSystem/Badges.swift index 4c17350..4bed38b 100644 --- a/CodegiOS/DesignSystem/Badges.swift +++ b/CodegiOS/DesignSystem/Badges.swift @@ -4,9 +4,10 @@ import SwiftUI struct AgentAvatar: View { let agent: AgentType var size: CGFloat = 36 + var remoteURL: URL? = nil var body: some View { - AgentIcon(agent: agent) + AgentIcon(agent: agent, remoteURL: remoteURL) .frame(width: size * 0.56, height: size * 0.56) .frame(width: size, height: size) .background(agent.accent.opacity(0.16), in: Circle()) diff --git a/CodegiOS/Features/Settings/Agents/AgentDetailView.swift b/CodegiOS/Features/Settings/Agents/AgentDetailView.swift index 82718fd..c30fa97 100644 --- a/CodegiOS/Features/Settings/Agents/AgentDetailView.swift +++ b/CodegiOS/Features/Settings/Agents/AgentDetailView.swift @@ -626,6 +626,10 @@ struct AgentConfigSection: View { case .cursor: CursorConfigSection(draft: $draft, client: client) case .kimiCode: KimiConfigSection(model: model, agent: agent, client: client) case .pi: PiConfigSection(model: model, agent: agent, client: client) + case .custom, .unknown: + Text("This agent has no iOS settings panel yet. Sign in from the desktop agent or its official CLI.") + .font(.footnote) + .foregroundStyle(.secondary) } } } diff --git a/CodegiOS/Features/Settings/Agents/AgentsSettingsView.swift b/CodegiOS/Features/Settings/Agents/AgentsSettingsView.swift index a770a46..53b9e7f 100644 --- a/CodegiOS/Features/Settings/Agents/AgentsSettingsView.swift +++ b/CodegiOS/Features/Settings/Agents/AgentsSettingsView.swift @@ -114,7 +114,11 @@ private struct AgentRow: View { HStack(spacing: 12) { Button(action: onOpen) { HStack(spacing: 12) { - AgentAvatar(agent: agent.agentType, size: 40) + AgentAvatar( + agent: agent.agentType, + size: 40, + remoteURL: agent.iconUrl.flatMap(URL.init(string:)) + ) VStack(alignment: .leading, spacing: 3) { HStack(spacing: 7) { Text(agent.name) diff --git a/CodegiOS/Models/AcpEvent.swift b/CodegiOS/Models/AcpEvent.swift index da36bb1..4db8dd5 100644 --- a/CodegiOS/Models/AcpEvent.swift +++ b/CodegiOS/Models/AcpEvent.swift @@ -392,6 +392,9 @@ struct AcpAgentInfo: Decodable, Identifiable, Hashable, Sendable { let registryId: String let name: String let description: String + /// Custom-agent mark from the server. Built-ins leave this nil and use + /// the shipped asset. + let iconUrl: String? let available: Bool /// `var` (not `let`) so the agents list model can optimistically flip an /// agent's enabled state for the instant row/detail toggle, reverting on a diff --git a/CodegiOS/Models/AgentType.swift b/CodegiOS/Models/AgentType.swift index 26b9078..dcde68c 100644 --- a/CodegiOS/Models/AgentType.swift +++ b/CodegiOS/Models/AgentType.swift @@ -1,29 +1,87 @@ import SwiftUI /// The coding agents codeg can drive. Wire value is snake_case (serde -/// `rename_all = "snake_case"` on the Rust `AgentType` enum). Enum *values* -/// are unaffected by the decoder's key strategy, so raw values match directly. -enum AgentType: String, Codable, CaseIterable, Hashable, Sendable, Identifiable { - case claudeCode = "claude_code" - case codex = "codex" - case openCode = "open_code" - case gemini = "gemini" - case openClaw = "open_claw" - case cline = "cline" - case hermes = "hermes" - case codeBuddy = "code_buddy" - case kimiCode = "kimi_code" - case pi = "pi" - case grok = "grok" - case cursor = "cursor" +/// `rename_all = "snake_case"` on the Rust `AgentType` enum). +/// +/// `custom:` and unknown built-in ids keep their original wire token so a +/// later encode (create conversation, acp_connect, settings writes) does not +/// collapse to `"custom"` / `"unknown"` or impersonate Claude. +enum AgentType: Hashable, Sendable, Identifiable { + case claudeCode + case codex + case openCode + case gemini + case openClaw + case cline + case hermes + case codeBuddy + case kimiCode + case pi + case grok + case cursor + /// User-registered ACP agent. Associated value is the full wire token + /// (`custom:claude-code-2`). + case custom(String) + /// Built-in added on the server after this app shipped, or a typo'd wire + /// id. Associated value is the original token. + case unknown(String) - var id: String { rawValue } + var id: String { wireValue } - /// Decodes unknown future agent types to `.claudeCode` rather than throwing, - /// so one new server-side agent can't break the whole list decode. - init(from decoder: Decoder) throws { - let raw = try decoder.singleValueContainer().decode(String.self) - self = AgentType(rawValue: raw) ?? .claudeCode + /// Built-in cases only. Custom/unknown ids come from the live server list. + static var allCases: [AgentType] { + [ + .claudeCode, .codex, .openCode, .gemini, .openClaw, .cline, + .hermes, .codeBuddy, .kimiCode, .pi, .grok, .cursor, + ] + } + + /// The token the server's `AgentType` enum understands. + var wireValue: String { + switch self { + case .claudeCode: return "claude_code" + case .codex: return "codex" + case .openCode: return "open_code" + case .gemini: return "gemini" + case .openClaw: return "open_claw" + case .cline: return "cline" + case .hermes: return "hermes" + case .codeBuddy: return "code_buddy" + case .kimiCode: return "kimi_code" + case .pi: return "pi" + case .grok: return "grok" + case .cursor: return "cursor" + case .custom(let raw), .unknown(let raw): return raw + } + } + + /// Compatibility with call sites that used `String` raw representable. + var rawValue: String { wireValue } + + static func parse(_ raw: String) -> AgentType { + switch raw { + case "claude_code": return .claudeCode + case "codex": return .codex + case "open_code": return .openCode + case "gemini": return .gemini + case "open_claw": return .openClaw + case "cline": return .cline + case "hermes": return .hermes + case "code_buddy": return .codeBuddy + case "kimi_code": return .kimiCode + case "pi": return .pi + case "grok": return .grok + case "cursor": return .cursor + default: + if raw.hasPrefix("custom:") { + return .custom(raw) + } + return .unknown(raw) + } + } + + init?(rawValue: String) { + self = Self.parse(rawValue) } var displayName: String { @@ -40,6 +98,11 @@ enum AgentType: String, Codable, CaseIterable, Hashable, Sendable, Identifiable case .pi: return "Pi" case .grok: return "Grok" case .cursor: return "Cursor" + case .custom(let raw): + let id = raw.dropFirst("custom:".count) + return id.isEmpty ? "Custom agent" : String(id) + case .unknown(let raw): + return raw.isEmpty ? "Unknown agent" : raw } } @@ -58,6 +121,8 @@ enum AgentType: String, Codable, CaseIterable, Hashable, Sendable, Identifiable case .pi: return "Pi" case .grok: return "Grok" case .cursor: return "Cursor" + case .custom: return "Custom" + case .unknown: return "Unknown" } } @@ -76,12 +141,11 @@ enum AgentType: String, Codable, CaseIterable, Hashable, Sendable, Identifiable case .pi: return "pi" case .grok: return "line.diagonal" case .cursor: return "cursorarrow" + case .custom, .unknown: return "questionmark.circle" } } - /// Name of the brand-icon image set in `Assets.xcassets` (the per-agent SVGs - /// ported verbatim from the web client's `agent-icon.tsx`). Rendered by - /// `AgentIcon`. + /// Name of the brand-icon image set in `Assets.xcassets`. var iconAsset: String { switch self { case .claudeCode: return "AgentClaudeCode" @@ -96,40 +160,44 @@ enum AgentType: String, Codable, CaseIterable, Hashable, Sendable, Identifiable case .pi: return "AgentPi" case .grok: return "AgentGrok" case .cursor: return "AgentCursor" + case .custom, .unknown: return "AgentUnknown" } } - /// Whether the brand asset is a monochrome (template) glyph that should be - /// tinted by the caller. Mirrors the web's `MONO_ICONS` set (OpenCode, Cline, - /// Hermes, CodeBuddy, Grok, Cursor); the others carry their own brand - /// colors/gradients and render as-is. var iconIsTemplate: Bool { switch self { - case .openCode, .cline, .hermes, .codeBuddy, .grok, .cursor: return true + case .openCode, .cline, .hermes, .codeBuddy, .grok, .cursor, .custom, .unknown: return true case .claudeCode, .codex, .gemini, .openClaw, .kimiCode, .pi: return false } } - /// Accent color used for badges / avatars per agent. var accent: Color { switch self { - case .claudeCode: return Color(red: 0.85, green: 0.52, blue: 0.34) // claude clay - case .codex: return Color(red: 0.45, green: 0.78, blue: 0.66) // teal - case .openCode: return Color(red: 0.55, green: 0.62, blue: 0.95) // indigo - case .gemini: return Color(red: 0.50, green: 0.70, blue: 0.98) // blue - case .openClaw: return Color(red: 0.92, green: 0.62, blue: 0.42) // amber - case .cline: return Color(red: 0.62, green: 0.78, blue: 0.50) // green - case .hermes: return Color(red: 0.60, green: 0.50, blue: 0.85) // violet - case .codeBuddy: return Color(red: 0.20, green: 0.47, blue: 0.96) // tencent blue - case .kimiCode: return Color(red: 0.09, green: 0.51, blue: 1.0) // moonshot blue - case .pi: return Color(red: 0.22, green: 0.22, blue: 0.26) // pi slate - // xAI's mark is monochrome (web `bg-neutral-900`); a static near-black - // would vanish on the dark card, so tint dynamically — near-black in - // light, near-white in dark — mirroring the brand while staying legible. + case .claudeCode: return Color(red: 0.85, green: 0.52, blue: 0.34) + case .codex: return Color(red: 0.45, green: 0.78, blue: 0.66) + case .openCode: return Color(red: 0.55, green: 0.62, blue: 0.95) + case .gemini: return Color(red: 0.50, green: 0.70, blue: 0.98) + case .openClaw: return Color(red: 0.92, green: 0.62, blue: 0.42) + case .cline: return Color(red: 0.62, green: 0.78, blue: 0.50) + case .hermes: return Color(red: 0.60, green: 0.50, blue: 0.85) + case .codeBuddy: return Color(red: 0.20, green: 0.47, blue: 0.96) + case .kimiCode: return Color(red: 0.09, green: 0.51, blue: 1.0) + case .pi: return Color(red: 0.22, green: 0.22, blue: 0.26) case .grok: return Color(light: Color(white: 0.12), dark: Color(white: 0.92)) - // Cursor's cube mark is monochrome too (web renders it at plain - // `text-foreground`), so it gets the same appearance-following tint. case .cursor: return Color(light: Color(white: 0.12), dark: Color(white: 0.92)) + case .custom, .unknown: return Color(light: Color(white: 0.35), dark: Color(white: 0.75)) } } } + +extension AgentType: Codable { + init(from decoder: Decoder) throws { + let raw = try decoder.singleValueContainer().decode(String.self) + self = AgentType.parse(raw) + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(wireValue) + } +} diff --git a/scripts/test_agent_type_wire.py b/scripts/test_agent_type_wire.py new file mode 100644 index 0000000..af761ec --- /dev/null +++ b/scripts/test_agent_type_wire.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Contract for CodegiOS AgentType.parse / encode. + +Mirrors CodegiOS/Models/AgentType.swift. Run on any machine: + python scripts/test_agent_type_wire.py +""" +from __future__ import annotations + +BUILTINS = { + "claude_code": "claude_code", + "codex": "codex", + "open_code": "open_code", + "gemini": "gemini", + "open_claw": "open_claw", + "cline": "cline", + "hermes": "hermes", + "code_buddy": "code_buddy", + "kimi_code": "kimi_code", + "pi": "pi", + "grok": "grok", + "cursor": "cursor", +} + + +def parse(raw: str) -> tuple[str, str]: + """Return (kind, wire_to_encode).""" + if raw in BUILTINS: + return raw, raw + if raw.startswith("custom:"): + return "custom", raw + return "unknown", raw + + +def encode(kind: str, wire: str) -> str: + return wire + + +def main() -> int: + cases = [ + ("grok", "grok", "grok"), + ("claude_code", "claude_code", "claude_code"), + ("custom:claude-code-2", "custom", "custom:claude-code-2"), + ("custom:codex-2", "custom", "custom:codex-2"), + ("not_a_real_agent", "unknown", "not_a_real_agent"), + ] + failed = 0 + for raw, kind, encoded in cases: + got_kind, got_wire = parse(raw) + got_enc = encode(got_kind, got_wire) + if got_kind == "claude_code" and raw != "claude_code": + print(f"FAIL {raw}: decoded as Claude") + failed += 1 + continue + if (got_kind, got_enc) != (kind, encoded): + print(f"FAIL {raw}: got {(got_kind, got_enc)} want {(kind, encoded)}") + failed += 1 + else: + print(f"ok {raw} -> {got_kind} encode={got_enc}") + if failed: + print(f"{failed} failed") + return 1 + print("all agent-type wire cases passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())