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
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,16 @@ enum StatusGlyphArt {
context.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: radius, startAngle: .pi / 2,
endAngle: .pi / 2 - 2 * .pi * progress, clockwise: true)
context.strokePath()
case .sweep:
context.setLineWidth(thickness)
context.setAlpha(config.trackOpacity)
context.addPath(StatusGlyphGeometry.ringPath(in: rect, thickness: thickness))
context.strokePath()
context.setAlpha(1)
let radius = max(0, rect.width / 2 - thickness / 2)
context.addArc(center: CGPoint(x: rect.midX, y: rect.midY), radius: radius, startAngle: 0,
endAngle: 2 * .pi * StatusIndicatorPlan.sweepLength, clockwise: false)
context.strokePath()
case .native:
guard let image = NativeSpinnerImage.image(side: CGFloat(pixels) / scale, scale: scale) else { return nil }
context.draw(image, in: rect)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,18 @@ public final class StatusIndicatorLayer {
shape.lineWidth = thickness
shape.strokeStart = 0
shape.strokeEnd = progress
case .sweep:
removeNative(); removeBraille(); removeDots()
let track = makeTrack(frame: rect)
track.path = StatusGlyphGeometry.ringPath(in: track.bounds, thickness: thickness)
track.lineWidth = thickness
track.opacity = Float(config.trackOpacity)
let shape = makeGlyph(frame: rect)
shape.path = CGPath(ellipseIn: shape.bounds.insetBy(dx: thickness / 2, dy: thickness / 2), transform: nil)
shape.fillColor = nil
shape.lineWidth = thickness
shape.strokeStart = 0
shape.strokeEnd = StatusIndicatorPlan.sweepLength
case .native:
removeGlyph(); removeTrack(); removeBraille(); removeDots()
let native = makeNative(frame: rect)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@ public nonisolated struct StatusIndicatorPlan: Hashable, Sendable {
case arc
/// Faint full track plus an arc of `progress`.
case ring(progress: Double)
/// Faint full track plus a short bright sweep (`sweepLength` of the circle, round caps) that
/// `spin` turns: the agent working-state mark, as Claude and ChatGPT draw theirs.
case sweep
/// The AppKit spinner's spokes. Determinate progress in the native
/// style draws `ring`, which is what the AppKit determinate circular
/// indicator is (a track and an arc).
Expand Down Expand Up @@ -80,12 +83,15 @@ public nonisolated struct StatusIndicatorPlan: Hashable, Sendable {

public static let hidden = StatusIndicatorPlan(glyph: .none, animation: nil, tint: .loading)

/// The agent working-state mark's glyph: the settled thin arc (`6388291c7c3`, 2026-10-02, and
/// #20410's Codex-like default), turning smoothly on the shared clock. Never the macOS activity
/// indicator's segmented spokes: #29903 swapped them in and Leo answered "why the f*** did you
/// make the spinner into this????" (2026-11-01). The spokes stay only as the explicit
/// "macOS Spinner" loading style.
public static let workingGlyph: Glyph = .arc
/// The agent working-state mark's glyph: a faint full ring with a short bright sweep turning
/// smoothly on the shared clock, as in Claude and ChatGPT. Neither the 3/4 arc (Leo 2026-10-31:
/// "who even decided to put that 3/4 circle") nor the macOS activity indicator's segmented spokes
/// (#29903; Leo 2026-11-01: "why the f*** did you make the spinner into this????"). The spokes
/// stay only as the explicit "macOS Spinner" loading style and the arc as loading's "Thin Arc".
public static let workingGlyph: Glyph = .sweep

/// The share of the circle the working sweep covers (about 80°).
public static let sweepLength: Double = 0.22

/// Agent work's spinner (the agent working-state mark), turning on the shared clock so every
/// working row moves in phase (#29903).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public nonisolated enum StatusIndicatorTunables {
"status.indicator.arcLength", .status, "Arc length", help: "Share of the circle the indeterminate arc covers.",
default: 0.72, range: 0.2...0.95, step: 0.01, unit: .fraction, code: "StatusIndicatorTunables.arcLength")
public static let trackOpacity = Tunable<Double>.number(
"status.indicator.trackOpacity", .status, "Ring track opacity", help: "Opacity of the full circle behind determinate progress.",
"status.indicator.trackOpacity", .status, "Ring track opacity", help: "Opacity of the full circle behind determinate progress and the working sweep.",
default: 0.22, range: 0...1, step: 0.01, unit: .fraction, code: "StatusIndicatorTunables.trackOpacity")
public static let dotScale = Tunable<Double>.number(
"status.indicator.dotScale", .status, "Dot size", help: "Dot diameter as a share of the indicator slot (busy dot, waiting, error).",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ public nonisolated enum StatusWorkingLook: String, Sendable, CaseIterable, Tunab
case breathe
/// The same dot held still; on web pages the working row's words shimmer.
case shimmer
/// The working spinner's turning arc (the default, `StatusIndicatorPlan.working`).
/// The working spinner: a faint ring with a short sweep turning (the default, `StatusIndicatorPlan.working`).
case spinner

/// The Debug Settings switch (section "Status Indicators").
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import CmuxNextDesignTokens

@Test func workSpinsInAGreyAndStandsStillAsARing() {
let spinning = Self.plan(.working)
#expect(spinning.glyph == .arc && spinning.animation == .spin && spinning.tint == .spinner)
#expect(spinning.glyph == .sweep && spinning.animation == .spin && spinning.tint == .spinner)
let still = Self.plan(.working, animates: false)
#expect(still.glyph == .mark(.vector(.idle)) && still.animation == nil && still.tint == .spinner)
// The grey is the theme's foreground at Codex's 66%, not a hue.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import Testing
indicator.apply(plan, config: config)
#expect(Self.loops(under: indicator.layer) { $0 == "opacity" || $0?.contains("scale") == true } == [], "\(label)")
if host.description != "TabCell+StatusBadge" {
#expect(plan.glyph == .arc && plan.animation == .spin, "\(label)")
#expect(plan.glyph == .sweep && plan.animation == .spin, "\(label)")
#expect(!Self.loops(under: indicator.layer) { $0 == "transform.rotation.z" }.isEmpty,
"\(label): the spinner must actually turn")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ import Testing
// started 0.37 s apart on the media clock, share a phase whatever the wall clock did.
let early = StatusIndicatorLayer(clock: SpinnerClock(mediaTime: { 500 }))
let late = StatusIndicatorLayer(clock: SpinnerClock(mediaTime: { 500.37 }))
let plan = StatusIndicatorPlan(glyph: .native, animation: .step, tint: .spinner)
let plan = StatusIndicatorPlan.working(tint: .spinner)
for indicator in [early, late] {
indicator.frame = CGRect(x: 0, y: 0, width: 16, height: 16)
indicator.apply(plan, config: StatusIndicatorConfig())
}
let first = try #require(early.nativeLayer?.animation(forKey: "step"))
let second = try #require(late.nativeLayer?.animation(forKey: "step"))
let first = try #require(early.glyphLayer?.animation(forKey: "spin"))
let second = try #require(late.glyphLayer?.animation(forKey: "spin"))
let turns = (second.beginTime - first.beginTime) / period
#expect(abs(turns - turns.rounded()) < 1e-6, "begin times \(first.beginTime) and \(second.beginTime) are out of step")
let clock = SpinnerClock(mediaTime: { 42 })
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import AppKit
@testable import CmuxNextDesign
import Testing
import CmuxNextDesignTokens
Expand All @@ -18,23 +19,41 @@ import CmuxNextDesignTokens
@Test func workBreathesAsAnAccentDot() {
#expect(Self.plan(.working, look: .breathe) == StatusIndicatorPlan(glyph: .dot, animation: .breathe, tint: .working))
#expect(Self.plan(.working, look: .shimmer) == StatusIndicatorPlan(glyph: .dot, animation: nil, tint: .working))
#expect(Self.plan(.working, look: .spinner) == StatusIndicatorPlan(glyph: .arc, animation: .spin, tint: .spinner))
#expect(Self.plan(.working, look: .spinner) == StatusIndicatorPlan(glyph: .sweep, animation: .spin, tint: .spinner))
}

/// The agent working-state mark is the settled thin arc turning smoothly, never the macOS activity
/// indicator's segmented spokes stepping round (#29903; Leo 2026-11-01: "why the f*** did you make
/// the spinner into this????"). Pinned for every icon set that draws work as a spinner.
@Test func theWorkingSpinnerIsTheSettledArcNeverSpokes() {
#expect(StatusIndicatorPlan.workingGlyph == .arc)
/// The agent working-state mark is a faint full ring with a short bright sweep turning smoothly,
/// as in Claude and ChatGPT: never the 3/4 arc alone (Leo 2026-10-31: "who even decided to put that
/// 3/4 circle") and never the macOS activity indicator's segmented spokes (#29903; Leo 2026-11-01:
/// "why the f*** did you make the spinner into this????"). Pinned for every icon set.
@Test func theWorkingSpinnerIsARingWithASweepNeverTheArcOrSpokes() {
#expect(StatusIndicatorPlan.workingGlyph == .sweep)
#expect(StatusIndicatorPlan.sweepLength > 0.15 && StatusIndicatorPlan.sweepLength < 0.26)
for set in StatusIconSet.allCases {
let plan = StatusIndicatorPlan.make(.working, style: .arc, animates: true, set: set)
#expect(plan.glyph != .native && plan.animation != .step, "\(set): \(plan)")
if plan.animation == .spin || plan.glyph == .arc {
#expect(plan.glyph == .arc && plan.animation == .spin, "\(set): \(plan)")
#expect(plan.glyph != .native && plan.glyph != .arc && plan.animation != .step, "\(set): \(plan)")
if plan.animation == .spin || plan.glyph == .sweep {
#expect(plan.glyph == .sweep && plan.animation == .spin, "\(set): \(plan)")
}
}
}

/// The sweep draws as a faint full track plus a short bright arc that the spin turns.
@Test func theWorkingSweepDrawsATrackAndAShortTurningArc() {
Motion.reduceMotionOverride = false
defer { Motion.reduceMotionOverride = nil }
let indicator = StatusIndicatorLayer(clock: SpinnerClock(mediaTime: { 100 }, unixTime: { 1_000 }))
indicator.frame = CGRect(x: 0, y: 0, width: 14, height: 14)
indicator.apply(StatusIndicatorPlan.working(tint: .spinner), config: StatusIndicatorConfig())
let sweep = indicator.glyphLayer
#expect(sweep?.strokeEnd == CGFloat(StatusIndicatorPlan.sweepLength))
#expect(sweep?.lineCap == .round)
#expect(sweep?.animation(forKey: "spin") != nil)
let track = indicator.layer.sublayers?.compactMap { $0 as? CAShapeLayer }.first { $0 !== sweep }
#expect(track != nil && track!.opacity > 0.1 && track!.opacity < 0.3)
#expect(indicator.nativeLayer == nil)
}

@Test func aHiddenOrStillRowHoldsTheDotStill() {
// Occluded, off screen or loops off: the dot stays a dot, it does not turn into the work ring.
#expect(Self.plan(.working, look: .breathe, animates: false) == StatusIndicatorPlan(glyph: .dot, animation: nil, tint: .working))
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
// l10n-allow-file: gallery fixtures, not shipped UI.
// The agent working-state mark's looks, in light and dark at 12, 14 and 16 px: (b) a faint ring
// with a short bright sweep (shipped, native and web), (c) a conic sweep with a fading tail, and
// the two rejected marks for reference: the 3/4 arc (Leo 2026-10-31: "who even decided to put that
// 3/4 circle") and the macOS spokes (Leo 2026-11-01: "why the f*** did you make the spinner into
// this????"). (b) won: at 12-14 px the conic tail fades into a smudge, while the sweep's head
// stays crisp and matches the native CAShapeLayer mark.
import { componentEntry } from "../../../gallery/format";
import type { SpinnerLooksBoardProps } from "./SpinnerLooksBoard";

export default componentEntry<SpinnerLooksBoardProps>({
id: "agent-pane.working-spinner",
title: "Working spinner: looks",
area: "Agent pane",
height: 240,
covers: [
"agent-session/acpmux/conversation/icons.tsx#Spinner",
"agent-session/acpmux/conversation/SpinnerLooksBoard.tsx",
],
keywords: ["spinner", "working", "ring", "sweep", "arc", "spokes", "conic"],
styles: () => import("./spinner.css"),
load: () => import("./SpinnerLooksBoard").then((module) => module.SpinnerLooksBoard),
variants: {
compare: {
note: "All four side by side; (b) ring + sweep ships.",
props: { looks: ["ring-sweep", "conic", "arc", "spokes"] },
},
"ring-sweep": {
note: "Shipped: a faint full ring (22%) with an 80° bright sweep, round caps, turning on the shared clock.",
props: { looks: ["ring-sweep"] },
},
conic: {
note: "Runner-up: a thin ring whose conic gradient fades behind a turning head.",
props: { looks: ["conic"] },
},
rejected: {
note: "For reference only: the 3/4 arc and the macOS spokes, both rejected by Leo.",
props: { looks: ["arc", "spokes"] },
},
},
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
/* Gallery only: the working mark's candidate looks (SpinnerLooksBoard.tsx). The shipped look is the
real `Spinner` (spinner.css); the conic sweep and the spokes are drawn here for comparison. */
.spinner-looks {
display: flex;
gap: 16px;
padding: 16px;
font: 12px/1.3 -apple-system, BlinkMacSystemFont, system-ui, sans-serif;
}
.spinner-looks__panel {
flex: 1;
display: grid;
gap: 10px;
padding: 14px 16px;
border-radius: 10px;
}
.spinner-looks__panel[data-scheme="light"] {
background: #ffffff;
color: rgb(0 0 0 / 66%);
box-shadow: inset 0 0 0 1px rgb(0 0 0 / 8%);
}
.spinner-looks__panel[data-scheme="dark"] {
background: #1f1f1f;
color: rgb(255 255 255 / 66%);
}
.spinner-looks__scheme {
font-weight: 600;
opacity: 0.8;
}
.spinner-looks__row {
display: grid;
grid-template-columns: 1fr repeat(3, 28px);
align-items: center;
}
.spinner-looks__cell {
display: inline-flex;
justify-content: center;
}
.spinner-looks__conic {
display: inline-block;
border-radius: 50%;
background: conic-gradient(from 0deg, transparent 0deg, currentColor 330deg, transparent 331deg);
-webkit-mask: radial-gradient(farthest-side, transparent calc(100% - 1.5px), #000 calc(100% - 1.5px));
mask: radial-gradient(farthest-side, transparent calc(100% - 1.5px), #000 calc(100% - 1.5px));
box-shadow: inset 0 0 0 1.5px color-mix(in srgb, currentColor 18%, transparent);
}
@media (prefers-reduced-motion: no-preference) {
:root:not([data-status-indicators="static"]) .spinner-looks__conic {
animation: cmux-spin var(--cmux-spinner-period, 900ms) linear infinite;
}
:root:not([data-status-indicators="static"]) .spinner-looks__spokes {
animation: cmux-spin var(--cmux-spinner-period, 900ms) steps(8, end) infinite;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
// l10n-allow-file: gallery board (look names and sizes), not shipped UI.
// The agent working-state mark's candidates side by side, in light and dark, at the sidebar's
// sizes (12 and 14 px) and the transcript's (16 px). Only "ring-sweep" is the real `Spinner`; the
// arc and the spokes are the two rejected marks (Leo 2026-10-31: "who even decided to put that 3/4
// circle"; 2026-11-01, on the spokes: "why the f*** did you make the spinner into this????"), and
// the conic sweep is the runner-up, drawn here only.
import "./SpinnerLooksBoard.css";
import { Spinner } from "./icons";

export type SpinnerLook = "ring-sweep" | "conic" | "arc" | "spokes";

export type SpinnerLooksBoardProps = {
/** The looks to draw, one row each, in order. */
looks: SpinnerLook[];
};

const LABELS: Record<SpinnerLook, string> = {
"ring-sweep": "(b) Ring + sweep (shipped)",
conic: "(c) Conic sweep",
arc: "3/4 arc (rejected 2026-10-31)",
spokes: "Spokes (rejected 2026-11-01)",
};

const SIZES = [12, 14, 16];

/** The spokes #29903 shipped (eight, the macOS activity indicator's), for reference only. */
const SPOKES = Array.from({ length: 8 }, (_, index) => ({
angle: index * 45,
opacity: index === 0 ? 1 : Math.max(0.25, 1 - (8 - index) * 0.11),
}));

function Mark({ look, size }: { look: SpinnerLook; size: number }) {
switch (look) {
case "ring-sweep":
return <Spinner size={size} />;
case "arc":
return <Spinner size={size} loading />;
case "conic":
return <span className="spinner-looks__conic" style={{ width: size, height: size }} aria-hidden="true" />;
case "spokes":
return (
<svg className="spinner-looks__spokes" width={size} height={size} viewBox="0 0 16 16" fill="none" aria-hidden="true">
<g stroke="currentColor" strokeWidth={1.5} strokeLinecap="round">
{SPOKES.map(({ angle, opacity }) => (
<line key={angle} x1="8" y1="1.75" x2="8" y2="4.75" opacity={opacity} transform={`rotate(${angle} 8 8)`} />
))}
</g>
</svg>
);
}
}

function Panel({ looks, scheme }: SpinnerLooksBoardProps & { scheme: "light" | "dark" }) {
return (
<div className="spinner-looks__panel" data-scheme={scheme}>
<div className="spinner-looks__scheme">{scheme === "light" ? "Light" : "Dark"}</div>
{looks.map((look) => (
<div key={look} className="spinner-looks__row" data-look={look}>
<span className="spinner-looks__label">{LABELS[look]}</span>
{SIZES.map((size) => (
<span key={size} className="spinner-looks__cell" title={`${size} px`}>
<Mark look={look} size={size} />
</span>
))}
</div>
))}
</div>
);
}

/** Every candidate look in light and dark, at 12, 14 and 16 px. */
export function SpinnerLooksBoard({ looks }: SpinnerLooksBoardProps) {
return (
<div className="spinner-looks">
<Panel looks={looks} scheme="light" />
<Panel looks={looks} scheme="dark" />
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2082,14 +2082,15 @@ pre.cv-math-source {
/* The running rows' mark (`Spinner`: compaction, tool groups, MCP calls, command and shell rows).
WORKING-LOOK: agent work draws the look the app picks, `data-working-look` on the root (Debug
Settings `status.working`):
- spinner (the default, also with no attribute or an unknown value): the turning arc;
- spinner (the default, also with no attribute or an unknown value): the faint ring with its
turning sweep;
- breathe (explicit choice only): the accent dot breathing on the shared clock;
- shimmer: a still dot in the theme accent, and a light band crosses the row's words.
A loading mark (no `data-working`) is always the ring. Every loop runs on the shared clock
(ui/spinnerClock.ts), so many rows move as one, and holds still under Reduce Motion and the
static look (the mark stopped). The mark's own default (dot hidden, the mark turning) lives in
spinner.css, which `Spinner` imports, so it holds on every page that draws one. */
:root:is([data-working-look="breathe"], [data-working-look="shimmer"]) .cv-spinner[data-working] .cv-spinner__arc {
:root:is([data-working-look="breathe"], [data-working-look="shimmer"]) .cv-spinner[data-working] :is(.cv-spinner__track, .cv-spinner__sweep) {
display: none;
}
:root:is([data-working-look="breathe"], [data-working-look="shimmer"]) .cv-spinner[data-working] .cv-spinner__dot {
Expand Down
Loading
Loading