Skip to content
Merged
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
6 changes: 3 additions & 3 deletions src/domains/domains.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,9 +35,9 @@ describe("parseDomain", () => {
});

it("converts an IDN to punycode and keeps the unicode form", () => {
const parsed = parseDomain("čez.cz");
expect(parsed?.domain).toBe("xn--ez-dma.cz");
expect(parsed?.unicode).toBe("čez.cz");
const parsed = parseDomain("příklad.cz");
expect(parsed?.domain).toBe("xn--pklad-zsa96e.cz");
expect(parsed?.unicode).toBe("příklad.cz");
});

it("rejects bare words and invalid labels", () => {
Expand Down
93 changes: 88 additions & 5 deletions src/macos/GenesisKit/Sources/GenesisKit/Controls/ForgeBadge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,8 +96,9 @@ public struct ForgeBadge: View {
}
}

/// The subset of SVG path data the forge marks use: M, L, H, V, C, S and Z, absolute and relative, with
/// implicit repeats and the compact number forms (`.4.07`, `-.17`). Anything else ends the path there.
/// The subset of SVG path data the forge and provider marks use: M, L, H, V, C, S, A and Z, absolute and relative,
/// with implicit repeats and the compact number forms (`.4.07`, `-.17`, arc flags packed as `01-.104`). Anything else
/// ends the path there.
enum SVGPath {
static func parse(_ data: String) -> Path {
var path = Path()
Expand Down Expand Up @@ -159,16 +160,82 @@ enum SVGPath {
path.addCurve(to: target, control1: first, control2: second)
current = target
lastControl = second
case "A":
guard let rx = tokens.number(), let ry = tokens.number(), let rotation = tokens.number(),
let large = tokens.flag(), let sweep = tokens.flag(), let target = point() else { return path }
addArc(to: &path, from: current, radii: CGSize(width: rx, height: ry), rotation: rotation,
large: large, sweep: sweep, end: target)
current = target
lastControl = nil
default:
return path
}
}
return path
}

/// An elliptical arc as cubic curves, at most a quarter turn each (SVG 1.1 implementation notes F.6.5 and F.6.6).
static func addArc(
to path: inout Path, from start: CGPoint, radii: CGSize, rotation: CGFloat, large: Bool, sweep: Bool,
end: CGPoint
) {
var rx = abs(radii.width)
var ry = abs(radii.height)
guard rx > 0, ry > 0, start != end else {
path.addLine(to: end)
return
}
let phi = rotation * .pi / 180
let cosPhi = cos(phi), sinPhi = sin(phi)
let dx = (start.x - end.x) / 2, dy = (start.y - end.y) / 2
let x1 = cosPhi * dx + sinPhi * dy
let y1 = -sinPhi * dx + cosPhi * dy
// Radii too small to reach the end point grow until they just do.
let lambda = (x1 * x1) / (rx * rx) + (y1 * y1) / (ry * ry)
if lambda > 1 {
rx *= sqrt(lambda)
ry *= sqrt(lambda)
}
let numerator = rx * rx * ry * ry - rx * rx * y1 * y1 - ry * ry * x1 * x1
let denominator = rx * rx * y1 * y1 + ry * ry * x1 * x1
var factor = sqrt(max(0, numerator / denominator))
if large == sweep { factor = -factor }
let cx1 = factor * rx * y1 / ry
let cy1 = -factor * ry * x1 / rx
let center = CGPoint(x: cosPhi * cx1 - sinPhi * cy1 + (start.x + end.x) / 2,
y: sinPhi * cx1 + cosPhi * cy1 + (start.y + end.y) / 2)
func angle(_ ux: CGFloat, _ uy: CGFloat, _ vx: CGFloat, _ vy: CGFloat) -> CGFloat {
let value = atan2(ux * vy - uy * vx, ux * vx + uy * vy)
return value
}
let theta = angle(1, 0, (x1 - cx1) / rx, (y1 - cy1) / ry)
var delta = angle((x1 - cx1) / rx, (y1 - cy1) / ry, (-x1 - cx1) / rx, (-y1 - cy1) / ry)
if !sweep, delta > 0 { delta -= 2 * .pi }
if sweep, delta < 0 { delta += 2 * .pi }
let segments = max(1, Int(ceil(abs(delta) / (.pi / 2))))
let step = delta / CGFloat(segments)
let k = 4 / 3 * tan(step / 4)
func onEllipse(_ t: CGFloat) -> CGPoint {
CGPoint(x: center.x + rx * cos(t) * cosPhi - ry * sin(t) * sinPhi,
y: center.y + rx * cos(t) * sinPhi + ry * sin(t) * cosPhi)
}
func tangent(_ t: CGFloat) -> CGPoint {
CGPoint(x: -rx * sin(t) * cosPhi - ry * cos(t) * sinPhi, y: -rx * sin(t) * sinPhi + ry * cos(t) * cosPhi)
}
var t = theta
for index in 0 ..< segments {
let next = t + step
let from = onEllipse(t), to = index == segments - 1 ? end : onEllipse(next)
let d1 = tangent(t), d2 = tangent(next)
path.addCurve(to: to, control1: CGPoint(x: from.x + k * d1.x, y: from.y + k * d1.y),
control2: CGPoint(x: to.x - k * d2.x, y: to.y - k * d2.y))
t = next
}
}

private enum Token: Equatable {
case command(Character)
case number(CGFloat)
case number(CGFloat, raw: String)
}

private struct Tokens {
Expand All @@ -178,7 +245,7 @@ enum SVGPath {
init(_ text: String) {
var number = ""
func flush() {
if let value = Double(number) { items.append(.number(CGFloat(value))) }
if let value = Double(number) { items.append(.number(CGFloat(value), raw: number)) }
number = ""
}
for char in text {
Expand Down Expand Up @@ -206,9 +273,25 @@ enum SVGPath {
mutating func advance() { index += 1 }

mutating func number() -> CGFloat? {
guard case .number(let value) = peek() else { return nil }
guard case .number(let value, _) = peek() else { return nil }
index += 1
return value
}

/// An arc flag: one digit, which may share its token with what follows (`01-.104` is flags 0 and 1).
mutating func flag() -> Bool? {
guard case .number(_, let raw) = peek(), let first = raw.first, first == "0" || first == "1" else {
return nil
}
let rest = String(raw.dropFirst())
if rest.isEmpty {
index += 1
} else if let value = Double(rest) {
items[index] = .number(CGFloat(value), raw: rest)
} else {
return nil
}
return first == "1"
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import SwiftUI

/// An agent's initial in its colour: C (Claude), X (Codex), G (Grok), else the first letter.
/// An agent's mark on a square in its colour (`AIProviderMark`): Claude, Codex, Grok; any other agent keeps its first
/// letter.
public struct ProviderBadge: View {
let provider: String
var size: CGFloat
Expand All @@ -23,11 +24,7 @@ public struct ProviderBadge: View {

public var body: some View {
let style = Self.style(for: provider)
Text(verbatim: style.letter)
.font(.system(size: (size * 0.56).rounded(), weight: .bold, design: .rounded))
.foregroundColor(.black.opacity(0.8))
.frame(width: size, height: size)
.background(RoundedRectangle(cornerRadius: size * 0.28).fill(style.color))
AIProviderGlyphBody(provider: provider, letter: style.letter, color: style.color, size: size, style: .filled)
.instantTooltip(tooltip ?? provider)
.accessibilityLabel(Text(tooltip ?? provider))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ public struct FileTreeRow: Identifiable, Equatable, Sendable {
}

/// A path as a list identity, hashed once. SwiftUI hashes and compares a lazy list's ids on every scroll frame, and a
/// non-ASCII `String` ("ČEZ") is normalized to NFC on each hash and each comparison: the profile of a tree scroll in
/// non-ASCII `String` ("Účty") is normalized to NFC on each hash and each comparison: the profile of a tree scroll in
/// the installed viewer had that normalization as its largest piece of app-side work (2026-10-15). Equal keys have
/// equal bytes. A listing path is already one form (`standardizedFileURL.path`, then `nativeUTF8`). A selected
/// document path can be the other Unicode form of that file, so `init(selection:)` applies the listing's form first.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import Foundation
/// Native string storage for paths shared between filesystem listings and the viewer.
extension String {
/// The same text in Swift's own contiguous UTF-8 storage. A path from `URL.path` or a Foundation listing is a
/// bridged `NSString` when it holds a non-ASCII letter (a vault folder named "ČEZ"); comparing, hashing or
/// bridged `NSString` when it holds a non-ASCII letter (a vault folder named "Účty"); comparing, hashing or
/// searching one walks it through CoreFoundation a character at a time. Measured 2026-10-15 on 636 vault paths:
/// finding one 0.42 ms bridged, 0.02 ms native; building a `Set` 1.42 ms against 0.36 ms.
var nativeUTF8: String {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import SwiftUI

/// A coding agent's logo as a shape: Claude's starburst, Codex's terminal cloud, Grok's slashed ring. Every surface
/// that names an agent by its mark draws it through this (`AIProviderGlyph`, `ProviderBadge`), so a change lands
/// everywhere at once. Paths from LobeHub's icon set (MIT, `@lobehub/icons-static-svg`), 24 × 24, even-odd fill.
public struct AIProviderMark: Shape {
public let kind: Kind

public enum Kind: String, CaseIterable, Sendable {
case claude, codex, grok

/// The mark for a provider id or alias (`claude`, `anthropic-sub`, `codex`, `openai-sub`, `grok`, `grok-sub`);
/// nil for one with no mark, which keeps its letter.
public init?(provider: String) {
switch provider.lowercased() {
case "claude", "anthropic", "anthropic-sub": self = .claude
case "codex", "openai", "openai-sub": self = .codex
case "grok", "xai", "grok-sub": self = .grok
default: return nil
}
}

/// The brand colour of the mark itself (Claude's terracotta; Codex and Grok are drawn in white).
public var brandColor: Color {
switch self {
case .claude: return Color(red: 0.851, green: 0.467, blue: 0.341)
case .codex, .grok: return Color.white.opacity(0.92)
}
}
}

public init(_ kind: Kind) {
self.kind = kind
}

public func path(in rect: CGRect) -> Path {
let unit = Self.paths[kind] ?? Path()
let scale = min(rect.width, rect.height) / 24
let dx = rect.minX + (rect.width - 24 * scale) / 2
let dy = rect.minY + (rect.height - 24 * scale) / 2
return unit.applying(CGAffineTransform(a: scale, b: 0, c: 0, d: scale, tx: dx, ty: dy))
}

/// Parsed once: these paths are drawn in every session row.
static let paths: [Kind: Path] = [
.claude: SVGPath.parse(
"M4.709 15.955l4.72-2.647.08-.23-.08-.128H9.2l-.79-.048-2.698-.073-2.339-.097-2.266-.122-.571-.121L0 11.784l.055-.352.48-.321.686.06 1.52.103 2.278.158 1.652.097 2.449.255h.389l.055-.157-.134-.098-.103-.097-2.358-1.596-2.552-1.688-1.336-.972-.724-.491-.364-.462-.158-1.008.656-.722.881.06.225.061.893.686 1.908 1.476 2.491 1.833.365.304.145-.103.019-.073-.164-.274-1.355-2.446-1.446-2.49-.644-1.032-.17-.619a2.97 2.97 0 01-.104-.729L6.283.134 6.696 0l.996.134.42.364.62 1.414 1.002 2.229 1.555 3.03.456.898.243.832.091.255h.158V9.01l.128-1.706.237-2.095.23-2.695.08-.76.376-.91.747-.492.584.28.48.685-.067.444-.286 1.851-.559 2.903-.364 1.942h.212l.243-.242.985-1.306 1.652-2.064.73-.82.85-.904.547-.431h1.033l.76 1.129-.34 1.166-1.064 1.347-.881 1.142-1.264 1.7-.79 1.36.073.11.188-.02 2.856-.606 1.543-.28 1.841-.315.833.388.091.395-.328.807-1.969.486-2.309.462-3.439.813-.042.03.049.061 1.549.146.662.036h1.622l3.02.225.79.522.474.638-.079.485-1.215.62-1.64-.389-3.829-.91-1.312-.329h-.182v.11l1.093 1.068 2.006 1.81 2.509 2.33.127.578-.322.455-.34-.049-2.205-1.657-.851-.747-1.926-1.62h-.128v.17l.444.649 2.345 3.521.122 1.08-.17.353-.608.213-.668-.122-1.374-1.925-1.415-2.167-1.143-1.943-.14.08-.674 7.254-.316.37-.729.28-.607-.461-.322-.747.322-1.476.389-1.924.315-1.53.286-1.9.17-.632-.012-.042-.14.018-1.434 1.967-2.18 2.945-1.726 1.845-.414.164-.717-.37.067-.662.401-.589 2.388-3.036 1.44-1.882.93-1.086-.006-.158h-.055L4.132 18.56l-1.13.146-.487-.456.061-.746.231-.243 1.908-1.312-.006.006z"),
.codex: SVGPath.parse(
"M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"),
.grok: SVGPath.parse(
"M9.27 15.29l7.978-5.897c.391-.29.95-.177 1.137.272.98 2.369.542 5.215-1.41 7.169-1.951 1.954-4.667 2.382-7.149 1.406l-2.711 1.257c3.889 2.661 8.611 2.003 11.562-.953 2.341-2.344 3.066-5.539 2.388-8.42l.006.007c-.983-4.232.242-5.924 2.75-9.383.06-.082.12-.164.179-.248l-3.301 3.305v-.01L9.267 15.292M7.623 16.723c-2.792-2.67-2.31-6.801.071-9.184 1.761-1.763 4.647-2.483 7.166-1.425l2.705-1.25a7.808 7.808 0 00-1.829-1A8.975 8.975 0 005.984 5.83c-2.533 2.536-3.33 6.436-1.962 9.764 1.022 2.487-.653 4.246-2.34 6.022-.599.63-1.199 1.259-1.682 1.925l7.62-6.815"),
]
}

/// How a provider glyph is drawn.
public enum AIProviderGlyphStyle: Sendable {
/// The mark in its provider colour on a tinted rounded square: row leaders, rails.
case tinted
/// The mark alone, in its provider colour: inline in text and tight spaces.
case plain
/// The mark in dark on a solid provider-coloured square (`ProviderBadge`).
case filled
}

/// One provider glyph in any style; a provider with no mark keeps its letter.
struct AIProviderGlyphBody: View {
let provider: String
let letter: String
let color: Color
let size: CGFloat
let style: AIProviderGlyphStyle

var body: some View {
let kind = AIProviderMark.Kind(provider: provider)
let inset = style == .plain ? 0 : size * 0.18
ZStack {
switch style {
case .tinted:
RoundedRectangle(cornerRadius: size / 4).fill(color.opacity(0.18))
case .filled:
RoundedRectangle(cornerRadius: size * 0.28).fill(color)
case .plain:
EmptyView()
}
if let kind {
AIProviderMark(kind)
.fill(style == .filled ? Color.black.opacity(0.8) : color, style: FillStyle(eoFill: true))
.padding(inset)
} else {
Text(verbatim: letter)
.font(.system(size: size * 0.6, weight: .bold, design: .rounded))
.foregroundStyle(style == .filled ? Color.black.opacity(0.8) : color)
}
}
.frame(width: size, height: size)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,23 +51,22 @@ public enum AIProviders {
}
}

/// The provider's one-letter glyph in its tinted box: the leader of an account row, and
/// the marker on a session row that is not Claude's.
/// The provider's mark (`AIProviderMark`) in its tinted box: the leader of an account row, and the marker on a session
/// row. A provider with no mark keeps its letter. `style` picks the tinted box, the bare mark or a solid badge.
public struct AIProviderGlyph: View {
public var meta: AIProviderMeta
public var size: CGFloat
public var style: AIProviderGlyphStyle

public init(meta: AIProviderMeta, size: CGFloat = 16) {
public init(meta: AIProviderMeta, size: CGFloat = 16, style: AIProviderGlyphStyle = .tinted) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📝 Docs | 🔵 Low · confidence 85/100

⚠️ Potential issue

Update the provider API guide for marks and glyph styles

The public provider guide in src/macos/GenesisKit/README.md:6636 and 6674–6679 still describes AIProviderGlyph as a one-letter tinted box and documents only meta and size (including the old letter-size ratio). This change replaces that behavior and adds the public style parameter, AIProviderGlyphStyle, and AIProviderMark, none of which the guide explains. Update the Providers section to describe known-provider marks, unknown-provider letter fallback, and the tinted/plain/filled styles so consumers can discover and correctly use the new public surface.

🧩 Analysis

Grep evidence: one-letter tinted box|one-letter glyph in a box|letter is 62.5|public enum AIProviderGlyphStyle

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted. The follow-up will update the provider API guide for marks and glyph styles. The pull request was merged after its fix rounds, so this finding waits for a follow-up, and this thread stays open until then.

self.meta = meta
self.size = size
self.style = style
}

public var body: some View {
Text(meta.glyph)
.font(.system(size: size * 0.625, weight: .bold, design: .monospaced))
.frame(width: size, height: size)
.background(meta.color.opacity(0.18), in: RoundedRectangle(cornerRadius: size / 4))
.foregroundStyle(meta.color)
AIProviderGlyphBody(provider: meta.alias, letter: meta.glyph, color: meta.color, size: size, style: style)
.accessibilityElement()
.accessibilityLabel(meta.displayName)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -1241,13 +1241,23 @@ private struct Avatar: View {
let glyph: String
let color: Color
var solid = false
/// An agent's id or alias: its mark (`AIProviderMark`) replaces the letter.
var provider: String?

var body: some View {
Text(verbatim: glyph)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(solid ? Color.black.opacity(0.85) : color)
.frame(width: 22, height: 22)
.background(Circle().fill(solid ? color : color.opacity(0.18)))
Group {
if let kind = provider.flatMap(AIProviderMark.Kind.init(provider:)) {
AIProviderMark(kind)
.fill(solid ? Color.black.opacity(0.85) : color, style: FillStyle(eoFill: true))
.padding(5)
} else {
Text(verbatim: glyph)
.font(.system(size: 11, weight: .bold))
.foregroundStyle(solid ? Color.black.opacity(0.85) : color)
}
}
.frame(width: 22, height: 22)
.background(Circle().fill(solid ? color : color.opacity(0.18)))
}
}

Expand Down Expand Up @@ -1467,7 +1477,7 @@ private struct ReplyBlock: View {

private var authorRow: some View {
HStack(spacing: 8) {
Avatar(glyph: provider.glyph, color: provider.color)
Avatar(glyph: provider.glyph, color: provider.color, provider: provider.alias)
Text(verbatim: provider.displayName)
.font(.system(size: 12.5, weight: .semibold))
.foregroundStyle(SessionPalette.text)
Expand Down
Loading
Loading