Skip to content

Commit 2f84622

Browse files
wxtskyclaude
andcommitted
fix(settings): every CLI row gets an icon — mascot renders + monogram fallback
Kiro and OpenClaw get real icons rendered from the in-house pixel mascots (new opt-in MascotRenderHarness/testRenderCliIcons export); sources without an asset (ZCode, custom CLIs) fall back to a rounded monogram tile with a deterministic per-source hue, so the settings hooks list never shows bare rows. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent a30462a commit 2f84622

4 files changed

Lines changed: 111 additions & 22 deletions

File tree

Sources/CodeIsland/NotchPanelView.swift

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2828,6 +2828,10 @@ private let cliIconFiles: [String: String] = [
28282828
"omp": "pi",
28292829
"opencode": "opencode",
28302830
"cline": "cline",
2831+
// Rendered from the in-house pixel mascots via
2832+
// MascotRenderHarness/testRenderCliIcons (MASCOT_ICON_DIR=…).
2833+
"kiro": "kiro",
2834+
"openclaw": "openclaw",
28312835
]
28322836

28332837
private var cliIconCache: [String: NSImage] = [:]
@@ -2838,12 +2842,48 @@ func cliIcon(source: String, size: CGFloat = 16) -> NSImage? {
28382842
guard let filename = cliIconFiles[source],
28392843
let url = Bundle.appModule.url(forResource: filename, withExtension: "png", subdirectory: "Resources/cli-icons"),
28402844
let image = NSImage(contentsOf: url)
2841-
else { return nil }
2845+
else {
2846+
// No asset (new integrations, custom CLIs): draw a monogram tile so
2847+
// every row in the settings CLI list still gets an icon.
2848+
let fallback = monogramIcon(for: source, size: size)
2849+
cliIconCache[key] = fallback
2850+
return fallback
2851+
}
28422852
image.size = NSSize(width: size, height: size)
28432853
cliIconCache[key] = image
28442854
return image
28452855
}
28462856

2857+
/// Rounded tile with the source's first letter; hue derived from the source
2858+
/// name so distinct CLIs get stable, distinct colors.
2859+
private func monogramIcon(for source: String, size: CGFloat) -> NSImage {
2860+
let letter = String(source.trimmingCharacters(in: CharacterSet(charactersIn: ".")).prefix(1)).uppercased()
2861+
// Deterministic hash — Swift's hashValue is seeded per launch and would
2862+
// repaint the tile a different color every run.
2863+
let stableHash = source.unicodeScalars.reduce(0) { ($0 &* 31 &+ Int($1.value)) & 0x7FFF_FFFF }
2864+
let hue = Double(stableHash % 360) / 360.0
2865+
let scale: CGFloat = 4 // draw at 4x so small sizes stay crisp
2866+
let px = size * scale
2867+
let image = NSImage(size: NSSize(width: px, height: px), flipped: false) { rect in
2868+
let bg = NSColor(hue: hue, saturation: 0.55, brightness: 0.72, alpha: 1)
2869+
NSBezierPath(roundedRect: rect.insetBy(dx: px * 0.02, dy: px * 0.02),
2870+
xRadius: px * 0.22, yRadius: px * 0.22).addClip()
2871+
bg.setFill()
2872+
rect.fill()
2873+
let font = NSFont.systemFont(ofSize: px * 0.58, weight: .bold)
2874+
let attrs: [NSAttributedString.Key: Any] = [
2875+
.font: font,
2876+
.foregroundColor: NSColor.white,
2877+
]
2878+
let text = NSAttributedString(string: letter, attributes: attrs)
2879+
let textSize = text.size()
2880+
text.draw(at: NSPoint(x: (px - textSize.width) / 2, y: (px - textSize.height) / 2))
2881+
return true
2882+
}
2883+
image.size = NSSize(width: size, height: size)
2884+
return image
2885+
}
2886+
28472887
private struct SessionTag: View {
28482888
let text: String
28492889
var color: Color = .white.opacity(0.7)
1.37 KB
Loading
1.61 KB
Loading

Tests/CodeIslandTests/MascotRenderHarness.swift

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,37 @@ import SwiftUI
1212
@MainActor
1313
final class MascotRenderHarness: XCTestCase {
1414

15+
/// One-frame icon export for cli-icons assets: renders each source's
16+
/// mascot at a fixed instant on a transparent background. Opt-in like the
17+
/// contact sheets (`MASCOT_ICON_DIR` + `MASCOT_ICON_SOURCES=kiro,openclaw`).
18+
func testRenderCliIcons() throws {
19+
guard let outDir = ProcessInfo.processInfo.environment["MASCOT_ICON_DIR"] else {
20+
throw XCTSkip("MASCOT_ICON_DIR not set — harness is opt-in")
21+
}
22+
let sources = (ProcessInfo.processInfo.environment["MASCOT_ICON_SOURCES"] ?? "kiro,openclaw")
23+
.split(separator: ",").map(String.init)
24+
let status: MascotAgentStatus = switch ProcessInfo.processInfo.environment["MASCOT_ICON_STATUS"] {
25+
case "idle": .idle
26+
case "waitingApproval": .waitingApproval
27+
case "waitingQuestion": .waitingQuestion
28+
default: .processing
29+
}
30+
let time = Double(ProcessInfo.processInfo.environment["MASCOT_ICON_TIME"] ?? "") ?? 0.0
31+
32+
try FileManager.default.createDirectory(atPath: outDir, withIntermediateDirectories: true)
33+
for source in sources {
34+
let icon = MascotIconFrame(source: source, status: status, time: time)
35+
let renderer = ImageRenderer(content: icon)
36+
renderer.scale = 2 // 64pt frame → 128px asset
37+
guard let cgImage = renderer.cgImage,
38+
let png = NSBitmapImageRep(cgImage: cgImage).representation(using: .png, properties: [:])
39+
else {
40+
XCTFail("icon render failed for \(source)"); continue
41+
}
42+
try png.write(to: URL(fileURLWithPath: "\(outDir)/\(source).png"))
43+
}
44+
}
45+
1546
func testRenderContactSheets() throws {
1647
guard let outDir = ProcessInfo.processInfo.environment["MASCOT_SHEET_DIR"] else {
1748
throw XCTSkip("MASCOT_SHEET_DIR not set — harness is opt-in")
@@ -48,6 +79,20 @@ final class MascotRenderHarness: XCTestCase {
4879
}
4980
}
5081

82+
/// Single transparent-background frame sized for a cli-icons asset.
83+
private struct MascotIconFrame: View {
84+
let source: String
85+
let status: MascotAgentStatus
86+
let time: Double
87+
88+
var body: some View {
89+
MascotContactSheet.routedMascot(source: source, status: status, size: 58)
90+
.environment(\.mascotAnimationsActive, false)
91+
.environment(\.mascotStaticTime, time)
92+
.frame(width: 64, height: 64)
93+
}
94+
}
95+
5196
/// One row of frames for a (source, status) pair, each frame rendered at a
5297
/// fixed timeline instant via the static-frame path of MascotTimeline
5398
/// (mascotAnimationsActive=false renders content(mascotStaticTime) once).
@@ -76,29 +121,33 @@ private struct MascotContactSheet: View {
76121
}
77122

78123
/// Direct routing (bypasses MascotView, which re-injects the live gate).
79-
@ViewBuilder
80124
private var mascot: some View {
125+
Self.routedMascot(source: source, status: status, size: 54)
126+
}
127+
128+
@ViewBuilder
129+
static func routedMascot(source: String, status: MascotAgentStatus, size: CGFloat) -> some View {
81130
switch source {
82-
case "codex": DexView(status: status, size: 54)
83-
case "gemini": GeminiView(status: status, size: 54)
84-
case "cursor": CursorView(status: status, size: 54)
85-
case "trae": TraeView(status: status, size: 54)
86-
case "copilot": CopilotView(status: status, size: 54)
87-
case "qoder": QoderView(status: status, size: 54)
88-
case "droid": DroidView(status: status, size: 54)
89-
case "codebuddy": BuddyView(status: status, size: 54)
90-
case "stepfun": StepFunView(status: status, size: 54)
91-
case "opencode": OpenCodeView(status: status, size: 54)
92-
case "qwen": QwenView(status: status, size: 54)
93-
case "antigravity": AntiGravityView(status: status, size: 54)
94-
case "workbuddy": WorkBuddyView(status: status, size: 54)
95-
case "hermes": HermesView(status: status, size: 54)
96-
case "openclaw": OpenClawView(status: status, size: 54)
97-
case "kiro": KiroView(status: status, size: 54)
98-
case "kimi": KimiView(status: status, size: 54)
99-
case "pi": PiView(status: status, size: 54)
100-
case "cline": ClineView(status: status, size: 54)
101-
default: ClawdView(status: status, size: 54)
131+
case "codex": DexView(status: status, size: size)
132+
case "gemini": GeminiView(status: status, size: size)
133+
case "cursor": CursorView(status: status, size: size)
134+
case "trae": TraeView(status: status, size: size)
135+
case "copilot": CopilotView(status: status, size: size)
136+
case "qoder": QoderView(status: status, size: size)
137+
case "droid": DroidView(status: status, size: size)
138+
case "codebuddy": BuddyView(status: status, size: size)
139+
case "stepfun": StepFunView(status: status, size: size)
140+
case "opencode": OpenCodeView(status: status, size: size)
141+
case "qwen": QwenView(status: status, size: size)
142+
case "antigravity": AntiGravityView(status: status, size: size)
143+
case "workbuddy": WorkBuddyView(status: status, size: size)
144+
case "hermes": HermesView(status: status, size: size)
145+
case "openclaw": OpenClawView(status: status, size: size)
146+
case "kiro": KiroView(status: status, size: size)
147+
case "kimi": KimiView(status: status, size: size)
148+
case "pi": PiView(status: status, size: size)
149+
case "cline": ClineView(status: status, size: size)
150+
default: ClawdView(status: status, size: size)
102151
}
103152
}
104153
}

0 commit comments

Comments
 (0)