Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 23 additions & 5 deletions CodegiOS/DesignSystem/AgentIcon.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion CodegiOS/DesignSystem/Badges.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
4 changes: 4 additions & 0 deletions CodegiOS/Features/Settings/Agents/AgentDetailView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}
Expand Down
6 changes: 5 additions & 1 deletion CodegiOS/Features/Settings/Agents/AgentsSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions CodegiOS/Models/AcpEvent.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
158 changes: 113 additions & 45 deletions CodegiOS/Models/AgentType.swift
Original file line number Diff line number Diff line change
@@ -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:<id>` 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 {
Expand All @@ -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
}
}

Expand All @@ -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"
}
}

Expand All @@ -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"
Expand All @@ -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)
}
}
67 changes: 67 additions & 0 deletions scripts/test_agent_type_wire.py
Original file line number Diff line number Diff line change
@@ -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())