diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTheme.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTheme.swift index 60a720c2a89b..05aee7be475f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTheme.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextAgentPane/AgentPaneTheme.swift @@ -72,6 +72,8 @@ enum AgentPaneTheme { // Static status indicators, or Reduce Motion (cx-sya4n): the page stops its status // loops and draws a shape per state (`data-status-indicators`). "statusIndicators": motion.reduceMotion || DesignSettings.shared.statusIndicator.animation == .static ? "static" : "animated", + // How agent work shows on the transcript's running rows (`data-working-look`). + "workingLook": StatusWorkingLook.tunable.value.rawValue, ] } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/Motion+AppKit.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/Motion+AppKit.swift index 1555949be351..270462a61a11 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/Motion+AppKit.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/Motion+AppKit.swift @@ -390,6 +390,21 @@ extension MotionGate { return sweep } + /// Agent work's breath (1 -> `low` -> 1 over one `breath` period, eased), or nil when loops + /// are off. Slower and softer than the waiting pulse, so a list of working rows reads calm. + public func breathAnimation(low: Float) -> CAAnimation? { + guard let period = period(.breath) else { return nil } + let breath = CABasicAnimation(keyPath: "opacity") + breath.fromValue = 1 + breath.toValue = low + breath.duration = period / 2 + breath.autoreverses = true + breath.repeatCount = .infinity + breath.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut) + breath.isRemovedOnCompletion = false + return breath + } + /// An opacity pulse (1 -> `low` -> 1), or nil when loops are off. public func pulseAnimation(low: Float) -> CAAnimation? { guard let period = period(.pulse) else { return nil } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionFadeTokens.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionFadeTokens.swift index df124c50e296..8be1e4094f81 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionFadeTokens.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionFadeTokens.swift @@ -63,6 +63,8 @@ public nonisolated enum MotionLoop: String, Sendable, CaseIterable { case flash /// Unmeasured progress: a segment crosses its track once. case sweep + /// Agent work's dot, one breath (bright, dim and back; `StatusWorkingLook.breathe`). + case breath /// Seconds per period, never under `minimumPeriod` (a Debug Settings /// override included). @@ -82,6 +84,8 @@ public nonisolated enum MotionLoop: String, Sendable, CaseIterable { case .pulse: Self.shortestFlashCycle case .flash: Self.shortestFlashCycle * 2 case .sweep: Self.shortestFlashCycle + // A breath is calm: never quicker than one a second. + case .breath: Self.shortestFlashCycle * 2 } } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift index 95d01d3df2f5..901858058c1f 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Motion/MotionTunables.swift @@ -89,6 +89,7 @@ public nonisolated enum MotionTunables { .pulse: (1.8, "One cycle of the agent-waiting pulse."), .flash: (1, "Pane attention flash (two blinks; at least 1 s, the 2 Hz cap)."), .sweep: (1.3, "One pass of an unmeasured progress bar's segment (the update card while checking)."), + .breath: (2.4, "One breath of the agent-working dot (working look Breathing Dot). Web pages breathe on their own --cmux-breath-period."), ] static let loops: [MotionLoop: Tunable] = Dictionary(uniqueKeysWithValues: MotionLoop.allCases.map { loop in diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Resources/Localizable.xcstrings b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Resources/Localizable.xcstrings index a7d5c10fd138..69698a262e5d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Resources/Localizable.xcstrings +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Resources/Localizable.xcstrings @@ -30677,6 +30677,402 @@ } } } + }, + "statusWorkingLook.breathe": { + "comment": "Working look option (Debug Settings): a small dot that slowly brightens and dims while an agent works.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Breathing Dot" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "نقطة نابضة" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Tačka koja diše" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Åndende prik" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Atmender Punkt" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Punto que respira" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Point respirant" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Punto che respira" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "呼吸するドット" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ចំណុចដកដង្ហើម" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "숨 쉬는 점" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Pustende prikk" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Oddychająca kropka" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Ponto que respira" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Дышащая точка" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "จุดหายใจ" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Nefes alan nokta" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Дихаюча крапка" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Chấm nhịp thở" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "呼吸圆点" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "呼吸圓點" + } + } + } + }, + "statusWorkingLook.shimmer": { + "comment": "Working look option (Debug Settings): a still dot, and a light band moves across the working row's text.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Shimmer" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "وميض" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Svjetlucanje" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Glimt" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Schimmer" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Destello" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Miroitement" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Luccichio" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "きらめき" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "ភ្លឺរលោង" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "반짝임" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Glimt" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Połysk" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Brilho" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Мерцание" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "ประกาย" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Parıltı" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Мерехтіння" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Ánh lướt" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "微光" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "微光" + } + } + } + }, + "statusWorkingLook.spinner": { + "comment": "Working look option (Debug Settings): a turning ring while an agent works.", + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Spinner" + } + }, + "ar": { + "stringUnit": { + "state": "needs_review", + "value": "دوّار" + } + }, + "bs": { + "stringUnit": { + "state": "needs_review", + "value": "Spiner" + } + }, + "da": { + "stringUnit": { + "state": "needs_review", + "value": "Spinner" + } + }, + "de": { + "stringUnit": { + "state": "needs_review", + "value": "Drehkreis" + } + }, + "es": { + "stringUnit": { + "state": "needs_review", + "value": "Indicador giratorio" + } + }, + "fr": { + "stringUnit": { + "state": "needs_review", + "value": "Indicateur rotatif" + } + }, + "it": { + "stringUnit": { + "state": "needs_review", + "value": "Indicatore rotante" + } + }, + "ja": { + "stringUnit": { + "state": "needs_review", + "value": "スピナー" + } + }, + "km": { + "stringUnit": { + "state": "needs_review", + "value": "រង្វង់វិល" + } + }, + "ko": { + "stringUnit": { + "state": "needs_review", + "value": "스피너" + } + }, + "nb": { + "stringUnit": { + "state": "needs_review", + "value": "Spinner" + } + }, + "pl": { + "stringUnit": { + "state": "needs_review", + "value": "Kółko ładowania" + } + }, + "pt-BR": { + "stringUnit": { + "state": "needs_review", + "value": "Indicador giratório" + } + }, + "ru": { + "stringUnit": { + "state": "needs_review", + "value": "Спиннер" + } + }, + "th": { + "stringUnit": { + "state": "needs_review", + "value": "วงหมุน" + } + }, + "tr": { + "stringUnit": { + "state": "needs_review", + "value": "Dönen halka" + } + }, + "uk": { + "stringUnit": { + "state": "needs_review", + "value": "Спінер" + } + }, + "vi": { + "stringUnit": { + "state": "needs_review", + "value": "Vòng xoay" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "needs_review", + "value": "旋转圈" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "needs_review", + "value": "旋轉圈" + } + } + } } }, "version": "1.0" diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIconSet+Image.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIconSet+Image.swift index 9222f9c5089f..5e7544d27820 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIconSet+Image.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIconSet+Image.swift @@ -13,8 +13,8 @@ extension StatusIconSet { appearance: NSAppearance? = nil) -> NSImage? { var state = state if let kind, case .waiting = state { state = .waiting(kind: kind) } - let config = StatusIndicatorConfig(iconSet: self) - let plan = StatusIndicatorPlan.make(state, style: config.settings.style, animates: false, set: self) + let config = StatusIndicatorConfig(iconSet: self, workingLook: StatusWorkingLook.tunable.value) + let plan = StatusIndicatorPlan.make(state, style: config.settings.style, animates: false, set: self, working: config.workingLook) let pixels = Int((pointSize * scale).rounded()) guard plan.glyph != .none, pixels > 0, let mask = StatusGlyphArt.mask(plan, pixels: pixels, scale: scale, config: config) else { return nil } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAnimationKey.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAnimationKey.swift index 111466ca5c6a..e65ae8fc0031 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAnimationKey.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAnimationKey.swift @@ -8,6 +8,7 @@ extension StatusIndicatorPlan.Animation { case .pulse: "pulse" case .frames: "frames" case .wave: "wave" + case .breathe: "breathe" } } @@ -18,6 +19,7 @@ extension StatusIndicatorPlan.Animation { case "pulse": self = .pulse case "frames": self = .frames case "wave": self = .wave + case "breathe": self = .breathe default: return nil } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAppearance.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAppearance.swift index 7ae2f47197ab..a8c98527b798 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAppearance.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorAppearance.swift @@ -32,12 +32,16 @@ public nonisolated struct StatusIndicatorConfig: Hashable, Sendable { /// The static look draws shapes instead of the Static set's glyphs /// (Debug Settings `status.static.shapes`). public var staticShapes: Bool + /// How agent work shows (Debug Settings `status.working`). + public var workingLook: StatusWorkingLook + /// Lowest opacity of the working dot's breath. + public var breathLow: Double { 0.3 } public init(settings: StatusIndicatorSettings = StatusIndicatorSettings(), styleOverride: StatusIndicatorStyle? = nil, arcLength: Double = 0.72, trackOpacity: Double = 0.22, dotScale: Double = 0.5, pulseLow: Double = 0.35, nativeSteps: Int = 8, animatesLoops: Bool = true, spinnerPeriod: Double? = nil, pulsePeriod: Double? = nil, terminalFontFamily: String? = nil, iconSet: StatusIconSet = .current, reduceMotion: Bool = false, - staticShapes: Bool = false) { + staticShapes: Bool = false, workingLook: StatusWorkingLook = .spinner) { self.settings = settings self.styleOverride = styleOverride self.arcLength = arcLength @@ -52,6 +56,7 @@ public nonisolated struct StatusIndicatorConfig: Hashable, Sendable { self.iconSet = iconSet self.reduceMotion = reduceMotion self.staticShapes = staticShapes + self.workingLook = workingLook } /// The static look (cx-sya4n): the setting says static, or Reduce Motion @@ -81,7 +86,8 @@ public nonisolated struct StatusIndicatorConfig: Hashable, Sendable { terminalFontFamily: DesignSettings.shared.terminalFontFamily, iconSet: StatusIconSet.tunable.value, reduceMotion: Motion.reduceMotion, - staticShapes: StatusIndicatorPlan.staticShapes.value) + staticShapes: StatusIndicatorPlan.staticShapes.value, + workingLook: StatusWorkingLook.tunable.value) } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorLayer.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorLayer.swift index e731e28cce0c..d2afa410055c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorLayer.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorLayer.swift @@ -21,13 +21,17 @@ public final class StatusIndicatorLayer { public var success: CGColor /// Agent work (`Tint.accent`); the loading color when nil. public var accent: CGColor? + /// The working dot (`Tint.working`, the theme accent); `accent` when nil. + public var working: CGColor? - public init(loading: CGColor, attention: CGColor, danger: CGColor, success: CGColor, accent: CGColor? = nil) { + public init(loading: CGColor, attention: CGColor, danger: CGColor, success: CGColor, accent: CGColor? = nil, + working: CGColor? = nil) { self.loading = loading self.attention = attention self.danger = danger self.success = success self.accent = accent + self.working = working } /// The theme's colors (call inside `performWithTheme` / a theme @@ -38,7 +42,8 @@ public final class StatusIndicatorLayer { attention: Palette.attention.cgColor, danger: Palette.danger.cgColor, success: Palette.success.cgColor, - accent: Palette.textPrimary.cgColor) + accent: Palette.textPrimary.cgColor, + working: Palette.accentFill.cgColor) } /// The unread dot: ``Palette/notificationBlue``. @@ -54,6 +59,7 @@ public final class StatusIndicatorLayer { case .accent: accent ?? loading case .spinner: (accent ?? loading).copy(alpha: (accent ?? loading).alpha * 0.66) ?? loading case .unread: Self.unread + case .working: working ?? accent ?? loading } } } @@ -123,7 +129,7 @@ public final class StatusIndicatorLayer { /// The animation running now (tests, diagnostics). public var runningAnimation: StatusIndicatorPlan.Animation? { - for (sublayer, key) in [(glyphLayer as CALayer?, "spin"), (markLayer, "spin"), (nativeLayer, "step"), (glyphLayer, "pulse"), (markLayer, "pulse"), (brailleLayer?.mask, "frames"), (dotsLayer?.sublayers?.first, "wave")] { + for (sublayer, key) in [(glyphLayer as CALayer?, "spin"), (markLayer, "spin"), (nativeLayer, "step"), (glyphLayer, "pulse"), (markLayer, "pulse"), (glyphLayer, "breathe"), (markLayer, "breathe"), (brailleLayer?.mask, "frames"), (dotsLayer?.sublayers?.first, "wave")] { if let sublayer, sublayer.animation(forKey: key) != nil { return StatusIndicatorPlan.Animation(key: key) } @@ -374,6 +380,7 @@ public final class StatusIndicatorLayer { case .pulse: Motion.indicators.pulseAnimation(low: Float(config.pulseLow)) case .frames: Motion.indicators.framesAnimation(brailleFrames) case .wave: waveAnimation() + case .breathe: Motion.indicators.breathAnimation(low: Float(config.breathLow)) } guard let animation else { return } // Every indicator loop runs on one clock (Lawrence 2026-10-14: "all spinners need to be @@ -390,7 +397,7 @@ public final class StatusIndicatorLayer { switch animation { case .step?: nativeLayer case .frames?: brailleLayer?.mask - case .spin?, .pulse?: markLayer ?? glyphLayer + case .spin?, .pulse?, .breathe?: markLayer ?? glyphLayer case .wave?: dotsLayer?.sublayers?.first case nil: nil } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorPlan.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorPlan.swift index 0e4b94ee6d97..ce44af7428f7 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorPlan.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorPlan.swift @@ -42,6 +42,9 @@ public nonisolated struct StatusIndicatorPlan: Hashable, Sendable { /// The dots dim and brighten one after another (one Motion `pulse` /// period per cycle). case wave + /// A slow opacity breath (Motion `breath` period): agent work's dot + /// (`StatusWorkingLook.breathe`). + case breathe } public enum Tint: Hashable, Sendable { @@ -59,6 +62,9 @@ public nonisolated struct StatusIndicatorPlan: Hashable, Sendable { /// Codex's unread blue: the Codex-like set's done dot. The one hue a /// status set adds (Leo's team asked for Codex's dot, 2026-10-14). case unread + /// The theme accent (`Palette.accentFill`: the text color, or the hue + /// `appearance.accent` names): agent work's dot (`StatusWorkingLook`). + case working } public var glyph: Glyph @@ -82,16 +88,18 @@ public nonisolated struct StatusIndicatorPlan: Hashable, Sendable { /// (`StatusIndicatorConfig.isStatic`) draws the static look whatever the /// set (`staticLookPlan`); `staticShapes` picks its shapes over the /// Static set's glyphs. + /// `working` restyles agent work that would turn as an arc (`StatusWorkingLook`). public static func make(_ state: StatusIndicatorState, style: StatusIndicatorStyle, animates: Bool, set: StatusIconSet = .current, isStatic: Bool = false, - staticShapes: Bool = false) -> StatusIndicatorPlan { + staticShapes: Bool = false, working: StatusWorkingLook = .spinner) -> StatusIndicatorPlan { if isStatic { return staticLookPlan(state, style: style, shapes: staticShapes) } - var plan = set.plan(for: state) ?? staticPlan(state, style: style) + var plan = working.restyle(set.plan(for: state) ?? staticPlan(state, style: style), for: state) if !animates { plan.animation = nil // The Codex-like set's still work mark is a whole ring, not a stopped arc (Leo's team, - // 2026-10-14: a static ring under Reduce Motion or the Static mode). Loading keeps its stopped arc. - if set == .codex, state.isWorking, state.progress == nil { plan.glyph = .mark(.vector(.idle)) } + // 2026-10-14: a static ring under Reduce Motion or the Static mode). Loading keeps its stopped arc; + // the working dot stays a dot. + if set == .codex, state.isWorking, state.progress == nil, plan.glyph == .arc { plan.glyph = .mark(.vector(.idle)) } } return plan } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorTunables.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorTunables.swift index 737d1d5991ff..e1aa36950891 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorTunables.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorTunables.swift @@ -26,6 +26,6 @@ public nonisolated enum StatusIndicatorTunables { public static var all: [TunableDescriptor] { [style.descriptor, arcLength.descriptor, trackOpacity.descriptor, dotScale.descriptor, pulseLow.descriptor, nativeSteps.descriptor, - StatusIconSet.tunable.descriptor, StatusIndicatorPlan.staticShapes.descriptor] + StatusIconSet.tunable.descriptor, StatusWorkingLook.tunable.descriptor, StatusIndicatorPlan.staticShapes.descriptor] } } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorView.swift index 18efe075adc4..df3a37b93c14 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusIndicatorView.swift @@ -98,7 +98,8 @@ public final class StatusIndicatorView: NSView, StatusIndicatorConfigClient { let visible = isWindowVisible || window?.occlusionState.contains(.visible) == true let animates = window != nil && visible && config.animatesLoops let plan = StatusIndicatorPlan.make(state, style: config.style(hint: styleHint), animates: animates, set: config.iconSet, - isStatic: config.isStatic, staticShapes: config.staticShapes) + isStatic: config.isStatic, staticShapes: config.staticShapes, + working: config.workingLook) let showed = showsGlyph nameState() indicator.apply(plan, config: config) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusWorkingLook.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusWorkingLook.swift new file mode 100644 index 000000000000..2015099e740d --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/StatusIndicator/StatusWorkingLook.swift @@ -0,0 +1,37 @@ +public import Foundation + +/// How agent work shows on a row, tab or transcript line (WORKING-LOOK, Leo 2026-10-20: seventeen +/// running sessions with a spinner each "reads as noise"; "isn't this mildly ugly?"). Applies where +/// a status icon set draws work as the turning arc (the Codex-like default and the spinner sets); +/// loading (a page, a command) keeps its spinner. Web pages get it as `data-working-look` +/// (`WebTheme.workingLook`). Under Reduce Motion or the static look nothing moves, whatever the look. +public nonisolated enum StatusWorkingLook: String, Sendable, CaseIterable, TunableChoice { + /// A small dot in the theme accent that breathes, one breath per Motion `breath` period, on the + /// shared clock: many working rows breathe as one (the default). + case breathe + /// The same dot held still; on web pages the working row's words shimmer. + case shimmer + /// The turning arc (before 2026-10-21). + case spinner + + /// The Debug Settings switch (section "Status Indicators"). + public static let tunable = Tunable.choice( + "status.working", .status, "Working look", + help: "How agent work shows on rows, tabs and transcript lines: a breathing accent dot (default), a still dot with the row's words shimmering (web pages), or the spinner.", + default: .breathe, code: "StatusWorkingLook.tunable") + + public var tunableTitle: String { + switch self { + case .breathe: String(localized: "statusWorkingLook.breathe", defaultValue: "Breathing Dot", bundle: .module) + case .shimmer: String(localized: "statusWorkingLook.shimmer", defaultValue: "Shimmer", bundle: .module) + case .spinner: String(localized: "statusWorkingLook.spinner", defaultValue: "Spinner", bundle: .module) + } + } + + /// `plan` restyled for this look: agent work drawn as the turning arc becomes the accent dot, + /// breathing for `breathe` and still for `shimmer`. Anything else is unchanged. + func restyle(_ plan: StatusIndicatorPlan, for state: StatusIndicatorState) -> StatusIndicatorPlan { + guard self != .spinner, state.isWorking, state.progress == nil, plan.glyph == .arc else { return plan } + return StatusIndicatorPlan(glyph: .dot, animation: self == .breathe ? .breathe : nil, tint: .working) + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WebTheme.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WebTheme.swift index 02070af161cc..257550350a49 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WebTheme.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextDesign/Windows/WebTheme.swift @@ -31,6 +31,9 @@ public nonisolated struct WebTheme: Equatable, Sendable { /// page's stylesheets stop their status loops. Static under Reduce Motion or /// `appearance.statusIndicator.animation: "static"` (`StatusIndicatorConfig.isStatic`). public let statusIndicators: String + /// `breathe`, `shimmer` or `spinner` (``StatusWorkingLook``): `data-working-look` on ``, + /// how a page draws agent work. + public let workingLook: String /// `off`, `reduced` or `full` (``motionValue(_:)``): `data-cmux-motion` on ``. Under /// `off` the bootstrap stylesheet makes every CSS transition and animation instant /// (`ui.animations.web` or the master switch `ui.animations.enabled` off). @@ -126,6 +129,7 @@ public nonisolated struct WebTheme: Equatable, Sendable { self.scrollers = scrollers ?? SystemScrollers.pageValue terminal = Terminal(tokens, fontFamily: DesignSettings.shared.effectiveTerminalFontFamily) statusIndicators = !Motion.indicators.animatesLoops || DesignSettings.shared.statusIndicator.animation == .static ? "static" : "animated" + workingLook = StatusWorkingLook.tunable.value.rawValue motion = Self.motionValue(Motion.web.policy) } @@ -137,7 +141,7 @@ public nonisolated struct WebTheme: Equatable, Sendable { return policy.reduceMotion ? "reduced" : "full" } - /// The payload `window.cmuxTheme.apply` takes: `{variables, colorScheme, scrollers, terminal, statusIndicators, motion}` + /// The payload `window.cmuxTheme.apply` takes: `{variables, colorScheme, scrollers, terminal, statusIndicators, workingLook, motion}` /// (`terminal.fontFamily` is null when the terminal has no named face). /// How the page's color variables move to new values (`appearance.colorTransition`, the same /// duration, curve and blend space as native): `{duration, easing, space}`. @@ -146,7 +150,7 @@ public nonisolated struct WebTheme: Equatable, Sendable { public var payloadJSON: String { let object: [String: Any] = ["variables": variables, "colorScheme": colorScheme, "scrollers": scrollers, "terminal": terminal.json, "transition": transition, "statusIndicators": statusIndicators, - "motion": motion] + "workingLook": workingLook, "motion": motion] guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.sortedKeys]), let json = String(data: data, encoding: .utf8) else { return "{}" } return json @@ -257,6 +261,7 @@ public nonisolated struct WebTheme: Equatable, Sendable { root.style.colorScheme = payload.colorScheme; if (payload.scrollers) root.setAttribute('data-scrollers', payload.scrollers); if (payload.statusIndicators) root.setAttribute('data-status-indicators', payload.statusIndicators); + if (payload.workingLook) root.setAttribute('data-working-look', payload.workingLook); if (payload.motion) root.setAttribute('data-cmux-motion', payload.motion); window.cmuxTheme.current = payload; window.dispatchEvent(new CustomEvent('cmux-theme', { detail: payload })); diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowContent.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowContent.swift index c0cc8206673f..ad446c01054a 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowContent.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Logic/WorkspaceRowContent.swift @@ -49,6 +49,10 @@ public nonisolated struct WorkspaceRowContent: Hashable, Sendable { /// Compact rows under a badge style (`SidebarRowBadgeStyle`): the dim time since the last /// change on the right, unless the second line shows the last activity already. public var badgeTime: String? + /// While the agent works: when it began (`SidebarWorkspaceDetail.activitySince`). The row + /// shows how long ("4m") beside the working dot when its name keeps its full width + /// (`WorkspaceRowView+Elapsed`); the hover card shows it to the second. + public var workingSince: Date? /// The status block under the name (classic's): the reported entries /// with `agentStatus` on, the log line with `log` on and the progress /// label with `progress` on. Nil when none of those is reported, so a @@ -115,6 +119,7 @@ public nonisolated struct WorkspaceRowContent: Hashable, Sendable { ) trailingStatus = trailing badgeTime = set.secondLine.contains(.lastActivity) ? nil : ws.lastActivity.map { Self.ageText($0, now: now) } + if case .working = activity { workingSince = ws.detail.activitySince } } /// An expanded row (`sidebar.density`, cx-13w3; T3 Code / Codex @@ -139,6 +144,7 @@ public nonisolated struct WorkspaceRowContent: Hashable, Sendable { expanded = true time = ws.lastActivity.map { Self.ageText($0, now: now) } self.meta = meta.isEmpty ? nil : meta.joined(separator: Self.separator) + if case .working = activity { workingSince = ws.detail.activitySince } } /// The age of a change, short: a time today, else days (`3d`), weeks @@ -154,6 +160,18 @@ public nonisolated struct WorkspaceRowContent: Hashable, Sendable { return formatter.string(from: TimeInterval(days) * 86_400) ?? "" } + /// How long a row has worked, in whole minutes ("4m", "1h 3m"); nil under a minute, so the + /// row's text changes once a minute at most. + static func workingElapsed(since date: Date, now: Date) -> String? { + let minutes = (now.timeIntervalSince(date) / 60).rounded(.down) + guard minutes >= 1 else { return nil } + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .abbreviated + formatter.maximumUnitCount = 2 + formatter.allowedUnits = [.day, .hour, .minute] + return formatter.string(from: minutes * 60) + } + /// The part of `reported` the shown elements draw; nil when empty. static func status(_ reported: SidebarWorkspaceStatus?, _ set: WorkspaceRowElements) -> SidebarWorkspaceStatus? { guard let reported else { return nil } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Badges.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Badges.swift index 5a8732c7211a..61c0ef2e2c26 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Badges.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Badges.swift @@ -19,7 +19,7 @@ extension WorkspaceRowView { return } // The icon, the cleared unread badge and the idle activity are set in `configure`. - if timeText == nil, let time = shown.badgeTime { + if timeText == nil, shown.workingSince == nil, let time = shown.badgeTime { timeText = time timeLabel.setString(time) } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Elapsed.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Elapsed.swift new file mode 100644 index 000000000000..57bc91b3ad29 --- /dev/null +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Elapsed.swift @@ -0,0 +1,40 @@ +import AppKit +import CmuxNextDesign +import Foundation + +/// A working row's running time (Leo 2026-10-20: "4m" when the row is wide): dim, left of the +/// working dot, in place of the age of the last change, and only while the name still fits whole +/// beside it, so a narrow sidebar keeps the row as it was. A one-line status there keeps its place. +/// The hover card shows the time to the second. +extension WorkspaceRowView { + /// Lays out the running time left of `trailing` and returns the new trailing edge; while it + /// shows, arms a tick for the next whole minute. + func layoutElapsed(trailing: CGFloat, textX: CGFloat, cy: CGFloat, height: CGFloat) -> CGFloat { + let now = Date() + let since = timeText == nil && !isShowingPlaceholder ? workingSince : nil + let text = since.flatMap { WorkspaceRowContent.workingElapsed(since: $0, now: now) } ?? "" + elapsedLabel.setString(text) + let w = ceil(elapsedLabel.attributedStringValue.size().width + 2 * Self.labelInset) + let fits = !text.isEmpty && textX + Self.labelInset + title.intrinsicContentSize.width + Metrics.space2 + w <= trailing + elapsedLabel.isHidden = !fits + // The first minute shows nothing yet; wake when it ends. A resize or a new state lays out anyway. + if let since, window != nil, !isHiddenOrHasHiddenAncestor, fits || text.isEmpty { + let intoMinute = now.timeIntervalSince(since).truncatingRemainder(dividingBy: 60) + elapsedTicker.scheduleIfIdle(after: .seconds(60 - max(0, intoMinute))) { @MainActor [weak self] in + self?.needsLayout = true + } + } else { + elapsedTicker.cancel() + } + guard fits else { return trailing } + elapsedLabel.frame = NSRect(x: trailing - w, y: cy - height / 2, width: w, height: height) + return trailing - w - Metrics.space1 + } + + /// A row taken from the pool stops its tick and lays out again, which re-arms it. A row that + /// returns to its own workspace keeps its content (`keepsContentOnReturn`), so the start stays. + func stopElapsed() { + elapsedTicker.cancel() + needsLayout = true + } +} diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Rendering.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Rendering.swift index 2014e6ad0ce1..d6d3cdd922ed 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Rendering.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView+Rendering.swift @@ -53,7 +53,7 @@ extension WorkspaceRowView { override func updateLayer() { performWithTheme { title.textColor = Palette.textPrimary - subtitle.setColor(Palette.textSecondary); [timeLabel, metaLabel].forEach { $0.setColor(Palette.textTertiary) } + subtitle.setColor(Palette.textSecondary); [timeLabel, metaLabel, elapsedLabel].forEach { $0.setColor(Palette.textTertiary) } tabCount.setColor(Palette.textTertiary) mutedMark.setTint(Palette.textTertiary) prBadge.setColor(Palette.textSecondary) @@ -197,6 +197,7 @@ extension WorkspaceRowView { agentMark.frame = NSRect(x: textX + Self.labelInset, y: cy - ind / 2, width: ind, height: ind) textX = agentMark.frame.maxX + Metrics.space1 } + trailing = layoutElapsed(trailing: trailing, textX: textX, cy: cy, height: sh) let textW = max(0, trailing - textX) let titleArrival = titleArrivalPending || titleArrivalActive title.isHidden = renaming || (isShowingPlaceholder && !titleArrival) @@ -217,7 +218,7 @@ extension WorkspaceRowView { title.setTrailingOcclusion(overlayStart.map { max(0, title.frame.maxX - ($0 - Metrics.space1)) } ?? 0, animated: fades) subtitle.wantsLayer = true subtitle.setTrailingFade(occlusion: overlayStart.map { max(0, subtitle.frame.maxX - ($0 - Metrics.space1)) } ?? 0, fade: fade, animated: fades) - for mark in [activity, rowBadge, agentMark, mutedMark, audioButton, presenceDots, timeLabel] as [NSView] { + for mark in [activity, rowBadge, agentMark, mutedMark, audioButton, presenceDots, timeLabel, elapsedLabel] as [NSView] { fadeOpacity(mark, to: overlayStart.map { mark.frame.maxX > $0 - Metrics.space1 } == true ? 0 : 1, fades: fades) } layoutStatus(x: textX, width: max(0, b.width - Metrics.space3 - textX), head: b.maxY, bottom: full.maxY) diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift index 02cc85739eb5..4f4c61e9004d 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextSidebar/Views/WorkspaceRowView.swift @@ -3,6 +3,7 @@ import Observation import CmuxAgentBrands import CmuxNextDesign import CmuxNextIcons +import CmuxNextWakeups import QuartzCore final class WorkspaceRowView: SidebarRowView { @@ -13,6 +14,10 @@ final class WorkspaceRowView: SidebarRowView { let subtitle = SidebarRowView.label(font: SidebarStyle.subtitleFont) /// Expanded rows (`sidebar.density`, cx-13w3; WorkspaceRowView+Expanded): the age and the meta line. let timeLabel = SidebarRowView.label(font: SidebarStyle.subtitleFont), metaLabel = SidebarRowView.label(font: SidebarStyle.subtitleFont) + /// How long the agent has worked ("4m"), beside the working dot when the name has room (WorkspaceRowView+Elapsed). + let elapsedLabel = SidebarRowView.label(font: SidebarStyle.subtitleFont) + var workingSince: Date? + let elapsedTicker = DemandTimer(owner: "sidebar.row.elapsed") let activity = StatusIndicatorView() /// The running agent's brand mark (`SidebarAgentMarkVariant`); hidden when off. internal let agentMark = DecorativeImageView() // the row's label says the agent's state @@ -118,7 +123,7 @@ final class WorkspaceRowView: SidebarRowView { mutedMark.imageScaling = .scaleProportionallyDown mutedMark.isHidden = true mutedMark.setAccessibilityElement(false) - [icon, title, subtitle, timeLabel, metaLabel, activity, agentMark, mutedMark, audioButton, badge, rowBadge, presenceDots, closeButton, moreButton, pinButton, disclosureButton, tabCount, prBadge, placeholderBar, statusBlock, + [icon, title, subtitle, timeLabel, metaLabel, elapsedLabel, activity, agentMark, mutedMark, audioButton, badge, rowBadge, presenceDots, closeButton, moreButton, pinButton, disclosureButton, tabCount, prBadge, placeholderBar, statusBlock, treeLine, pullRequests, keycap].forEach(addSubview) setUpSessionTree() disclosureButton.isHidden = true @@ -177,6 +182,7 @@ final class WorkspaceRowView: SidebarRowView { onToggleTabs = nil onToggleAudio = nil title.stopMarquee() + stopElapsed() title.layer?.removeAnimation(forKey: "workspace-title-motion") lastTitle = nil titleArrivalGeneration &+= 1 @@ -251,8 +257,12 @@ final class WorkspaceRowView: SidebarRowView { subtitle.setFont(SidebarStyle.subtitleFont) subtitle.setString(row.detail ?? "") hasSubtitle = row.detail != nil - (isExpanded, timeText) = (shown.expanded, shown.trailingText) - timeLabel.setString(shown.trailingText ?? "") + // While the agent works, its running time takes the age's place (WorkspaceRowView+Elapsed). + let trailingText = shown.workingSince != nil ? shown.trailingStatus : shown.trailingText + (isExpanded, timeText) = (shown.expanded, trailingText) + timeLabel.setString(trailingText ?? "") + if workingSince != shown.workingSince { elapsedTicker.cancel() } + workingSince = shown.workingSince metaLabel.setString(shown.meta ?? "") activity.configure(badgesLead ? .idle : shown.activity, style: badgesLead ? nil : ws.activityStyle) activityState = shown.activity diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift index 1b9dcaa2650a..d8242d0b851c 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusBadge.swift @@ -24,7 +24,7 @@ extension TabCell { func statusBadgePlan(config: StatusIndicatorConfig) -> StatusIndicatorPlan? { guard config.iconSet != .current || config.isStatic, let state = statusBadgeState else { return nil } let plan = StatusIndicatorPlan.make(state, style: config.settings.style, animates: false, set: config.iconSet, isStatic: config.isStatic, - staticShapes: config.staticShapes) + staticShapes: config.staticShapes, working: config.workingLook) return plan.glyph == .none ? nil : plan } diff --git a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift index 512e6b29d35b..c0ba97977de4 100644 --- a/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift +++ b/Packages/macOS/CmuxNext/Sources/CmuxNextTabs/TabCell+StatusIndicator.swift @@ -17,7 +17,7 @@ extension TabCell: StatusIndicatorConfigClient { // spinners on screen turning opposite ways). return StatusIndicatorPlan.make(item.indicator, style: config.style(hint: item.busyStyle), animates: config.animatesLoops && isWindowVisible, set: config.iconSet, isStatic: config.isStatic, - staticShapes: config.staticShapes) + staticShapes: config.staticShapes, working: config.workingLook) } /// Creates, updates or removes the indicator for the current item and diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceRowElapsedTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceRowElapsedTests.swift new file mode 100644 index 000000000000..57df26a26565 --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextAppTests/WorkspaceRowElapsedTests.swift @@ -0,0 +1,100 @@ +import AppKit +import CmuxNextDesign +@testable import CmuxNextSidebar +import Foundation +import Testing + +/// A working row shows how long the agent has worked ("4m", Leo 2026-10-20) beside its dot when +/// the name keeps its full width; a narrow row stays as it was. +@MainActor +struct WorkspaceRowElapsedTests { + private func working(_ title: String, since: Date?) -> SidebarWorkspace { + // An agent's last update gives the row an age, which the running time replaces. + var ws = SidebarWorkspace(id: WorkspaceID("ws"), title: title, lastActivity: Date(), agentWorking: true, + activity: .working(progress: nil)) + ws.detail.activitySince = since + return ws + } + + /// The row as the list shows it, `width` wide, in a window so its minute tick may arm. + private func row(_ ws: SidebarWorkspace, width: CGFloat) -> (WorkspaceRowView, NSWindow) { + let content = WorkspaceRowContent(ws, preferences: .defaults) + let row = SidebarRow(key: .workspace(ws.id), y: 0, height: 28, section: .pinned, group: nil, siblingIndex: 0, + parentIndex: nil, isLastInGroup: false, isCollapsed: false, childCount: 0, groupColor: nil, + content: content) + let view = WorkspaceRowView(key: row.key) + let window = NSWindow(contentRect: NSRect(x: 0, y: 0, width: width, height: 28), styleMask: .borderless, + backing: .buffered, defer: true) + window.contentView?.addSubview(view) + view.frame = NSRect(x: 0, y: 0, width: width, height: 28) + view.configure(ws, row: row, badgeStyle: .corner) + view.layoutSubtreeIfNeeded() + return (view, window) + } + + @Test func theRunningTimeReadsInWholeMinutes() { + let now = Date() + #expect(WorkspaceRowContent.workingElapsed(since: now.addingTimeInterval(-59), now: now) == nil) + #expect(WorkspaceRowContent.workingElapsed(since: now.addingTimeInterval(-4 * 60 - 30), now: now) == "4m") + #expect(WorkspaceRowContent.workingElapsed(since: now.addingTimeInterval(-63 * 60), now: now) == "1h 3m") + } + + @Test func onlyAWorkingRowCarriesItsStart() { + let since = Date().addingTimeInterval(-300) + #expect(WorkspaceRowContent(working("Fix the build", since: since), preferences: .defaults).workingSince == since) + var idle = working("Fix the build", since: since) + (idle.agentWorking, idle.activity) = (false, .idle) + #expect(WorkspaceRowContent(idle, preferences: .defaults).workingSince == nil) + } + + @Test func aWideRowShowsTheTimeBesideTheDot() { + let (view, window) = row(working("Fix the build", since: Date().addingTimeInterval(-4 * 60 - 30)), width: 320) + #expect(!view.elapsedLabel.isHidden) + #expect(view.elapsedLabel.stringValue == "4m") + // Between the whole name and the trailing marks. + #expect(view.title.frame.maxX <= view.elapsedLabel.frame.minX) + #expect(view.title.frame.width >= view.title.intrinsicContentSize.width) + #expect(view.elapsedLabel.frame.maxX <= view.rowBadge.frame.minX) + #expect(view.timeLabel.isHidden) + window.close() + } + + /// A row parked in the pool and taken back for its own workspace keeps its content, and its time. + @Test func aRowBackFromThePoolKeepsItsTime() { + let ws = working("Fix the build", since: Date().addingTimeInterval(-4 * 60 - 30)) + let (view, window) = row(ws, width: 320) + view.prepareForReuse(key: .workspace(ws.id)) + let content = WorkspaceRowContent(ws, preferences: .defaults) + view.configure(ws, row: SidebarRow(key: .workspace(ws.id), y: 0, height: 28, section: .pinned, group: nil, siblingIndex: 0, + parentIndex: nil, isLastInGroup: false, isCollapsed: false, childCount: 0, + groupColor: nil, content: content), badgeStyle: .corner) + view.layoutSubtreeIfNeeded() + #expect(!view.elapsedLabel.isHidden) + #expect(view.elapsedLabel.stringValue == "4m") + window.close() + } + + @Test func aNarrowRowKeepsItsName() { + let (view, window) = row(working("Redesign the source control pane's commit flow", since: Date().addingTimeInterval(-600)), + width: 200) + #expect(view.elapsedLabel.isHidden) + window.close() + } + + @Test func underAMinuteShowsNothing() { + let (view, window) = row(working("Fix the build", since: Date().addingTimeInterval(-10)), width: 320) + #expect(view.elapsedLabel.isHidden) + #expect(view.timeLabel.isHidden) + window.close() + } + + /// A quiet row keeps the age of its last change. + @Test func anIdleRowKeepsItsAge() { + var idle = working("Fix the build", since: nil) + (idle.agentWorking, idle.activity) = (false, .idle) + let (view, window) = row(idle, width: 320) + #expect(view.elapsedLabel.isHidden) + #expect(!view.timeLabel.isHidden) + window.close() + } +} diff --git a/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/StatusWorkingLookTests.swift b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/StatusWorkingLookTests.swift new file mode 100644 index 000000000000..630ae230e7da --- /dev/null +++ b/Packages/macOS/CmuxNext/Tests/CmuxNextDesignTests/StatusWorkingLookTests.swift @@ -0,0 +1,42 @@ +@testable import CmuxNextDesign +import Testing + +/// WORKING-LOOK (Leo 2026-10-20: seventeen running sessions with a spinner each "reads as noise"): +/// agent work draws a calm dot in the theme accent by default, breathing on the shared clock; the +/// spinner stays as an option, and loading (a page, a command) keeps its spinner whatever the look. +@MainActor struct StatusWorkingLookTests { + private static func plan(_ state: StatusIndicatorState, look: StatusWorkingLook, animates: Bool = true, + isStatic: Bool = false) -> StatusIndicatorPlan { + StatusIndicatorPlan.make(state, style: .arc, animates: animates, set: .codex, isStatic: isStatic, working: look) + } + + @Test func theBreathingDotIsTheDefault() { + #expect(StatusWorkingLook.tunable.defaultValue == .breathe) + } + + @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)) + } + + @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)) + // The static look (Reduce Motion) keeps its own still glyphs. + let still = Self.plan(.working, look: .breathe, isStatic: true) + #expect(still.animation == nil && still.tint != .working) + } + + @Test func loadingAndKnownProgressKeepTheirMarks() { + for look in StatusWorkingLook.allCases { + #expect(Self.plan(.busy, look: look).glyph == .arc, "\(look): loading keeps its spinner") + #expect(Self.plan(.working(progress: 0.4), look: look).glyph == .ring(progress: 0.4), "\(look): known progress keeps its ring") + } + } + + @Test func theBreathIsCalmerThanTheWaitingPulse() { + #expect(MotionLoop.breath.minimumPeriod >= 1) + #expect((MotionTunables.loopDefaults[.breath]?.0 ?? 0) > (MotionTunables.loopDefaults[.pulse]?.0 ?? 0)) + } +} diff --git a/webviews/src/agent-session/acpmux/HarnessHealthNotice.tsx b/webviews/src/agent-session/acpmux/HarnessHealthNotice.tsx index 1a089a7b09df..207e99c2304b 100644 --- a/webviews/src/agent-session/acpmux/HarnessHealthNotice.tsx +++ b/webviews/src/agent-session/acpmux/HarnessHealthNotice.tsx @@ -271,7 +271,7 @@ export function HarnessHealthNotice({ >

- {busy && } + {busy && } {text}

diff --git a/webviews/src/agent-session/acpmux/conversation/WorkingLook.gallery.ts b/webviews/src/agent-session/acpmux/conversation/WorkingLook.gallery.ts new file mode 100644 index 000000000000..e367f58c3ae2 --- /dev/null +++ b/webviews/src/agent-session/acpmux/conversation/WorkingLook.gallery.ts @@ -0,0 +1,54 @@ +// l10n-allow-file: gallery fixtures (sample prompts and tool titles), not shipped UI. +// WORKING-LOOK (Leo 2026-10-20: a spinner on every running row "reads as noise"): the transcript's +// running rows in each look agent work can take (`data-working-look`, Debug Settings +// `status.working`). Breathing Dot is the default; Shimmer and Spinner stay as options. +import { agentPaneEntry } from "../../../gallery/format"; +import { activity, chat, row, tool, user } from "../../../gallery/fixtures/acpmux"; + +const busy = () => + chat([ + user("Fix the flaky retry test, then run the suite", 4), + row("compaction", 3, { compaction: { state: "running" } }), + activity( + [ + tool("Read src/fetch/retry.ts", "read", "running"), + tool("Search for withRetry callers", "search", "running"), + tool("bun test src/fetch", "execute", "running"), + ], + 1, + ), + ]); + +export default agentPaneEntry({ + id: "agent-pane.working-look", + title: "Working look: running rows", + area: "Agent pane", + height: 360, + covers: [ + "agent-session/acpmux/conversation/CompactionNote.tsx", + "agent-session/acpmux/conversation/ToolGroupRow.tsx", + ], + keywords: ["spinner", "working", "breathing", "shimmer", "running"], + variants: { + breathe: { + note: "The default: a dot in the theme accent breathes once every 2.4 s, all rows in step.", + snapshot: busy(), + theme: { workingLook: "breathe" }, + }, + shimmer: { + note: "The dot holds still and a light band crosses each running row's words.", + snapshot: busy(), + theme: { workingLook: "shimmer" }, + }, + spinner: { + note: "The turning ring on every running row (before 2026-10-21).", + snapshot: busy(), + theme: { workingLook: "spinner" }, + }, + static: { + note: "Reduce Motion or Static status indicators: the dot holds at full strength.", + snapshot: busy(), + theme: { workingLook: "breathe", statusIndicators: "static" }, + }, + }, +}); diff --git a/webviews/src/agent-session/acpmux/conversation/conversation.css b/webviews/src/agent-session/acpmux/conversation/conversation.css index 4598bc55c109..1d308458a588 100644 --- a/webviews/src/agent-session/acpmux/conversation/conversation.css +++ b/webviews/src/agent-session/acpmux/conversation/conversation.css @@ -1316,12 +1316,53 @@ pre.cv-math-source { .cv-tool.is-strong { color: var(--cv-tool-strong); } -/* The running rows' spinner (`Spinner`: compaction, tool groups, MCP calls, command and shell rows): - the shared `cmux-spin` turn (ui/spinnerClock.ts); it holds still under Reduce Motion and the static look. */ +/* The running rows' mark (`Spinner`: compaction, tool groups, MCP calls, command and shell rows). + WORKING-LOOK (Leo 2026-10-20: a spinner per row "reads as noise"): agent work draws the look the + app picks, `data-working-look` on the root (Debug Settings `status.working`): + - breathe (the default, also with no attribute): a dot in the theme accent that breathes once per + `--cmux-breath-period`; + - shimmer: the same dot held still, and a light band crosses the row's words; + - spinner: the turning ring. + 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 dot at full strength, the ring stopped). */ +.cv-spinner__dot { + display: none; + fill: var(--cmux-accent, currentColor); +} +:root:not([data-working-look="spinner"]) .cv-spinner[data-working] .cv-spinner__arc { + display: none; +} +:root:not([data-working-look="spinner"]) .cv-spinner[data-working] .cv-spinner__dot { + display: inline; +} @media (prefers-reduced-motion: no-preference) { - :root:not([data-status-indicators="static"]) .cv-spinner { + :root:not([data-status-indicators="static"]) .cv-spinner:not([data-working]), + :root:not([data-status-indicators="static"])[data-working-look="spinner"] .cv-spinner { animation: cmux-spin var(--cmux-spinner-period, 900ms) linear infinite; } + :root:not([data-status-indicators="static"]):not([data-working-look="spinner"]):not([data-working-look="shimmer"]) + .cv-spinner[data-working] + .cv-spinner__dot { + animation: cmux-breathe var(--cmux-breath-period, 2.4s) ease-in-out infinite; + } + :root:not([data-status-indicators="static"])[data-working-look="shimmer"] + .cv-tool:has(> .cv-tool__icon .cv-spinner[data-working]) + .cv-tool__text { + background: linear-gradient(100deg, var(--cv-text-dim) 30%, var(--cv-text) 50%, var(--cv-text-dim) 70%) 0 0 / + 250% 100%; + -webkit-background-clip: text; + background-clip: text; + -webkit-text-fill-color: transparent; + animation: cmux-shimmer var(--cmux-breath-period, 2.4s) linear infinite; + } + /* Marks with their own color (edit counts, a failed count) keep it inside the band. */ + :root:not([data-status-indicators="static"])[data-working-look="shimmer"] + .cv-tool:has(> .cv-tool__icon .cv-spinner[data-working]) + .cv-tool__text + :is(.cv-edit-diff__add, .cv-edit-diff__del, .cv-tool__failed) { + -webkit-text-fill-color: currentColor; + } } .cv-tool__icon { display: flex; diff --git a/webviews/src/agent-session/acpmux/conversation/icons.tsx b/webviews/src/agent-session/acpmux/conversation/icons.tsx index eb0cc92c267d..53bc1a248e2b 100644 --- a/webviews/src/agent-session/acpmux/conversation/icons.tsx +++ b/webviews/src/agent-session/acpmux/conversation/icons.tsx @@ -208,8 +208,17 @@ export const GitHubMark = ({ size = 16, className, style }: CvIconProps) => ( ); -/** Open-ended ring used as the "working" spinner. `cv-spinner` turns it (conversation.css). */ -export const Spinner = ({ size = 16, className, style, strokeWidth = 1.5 }: CvIconProps) => ( +/** The running mark of a transcript row. Agent work (the default) draws the working look the + * app picks (`data-working-look` on the root, conversation.css): a breathing dot, the turning ring, + * or a still dot while the row's words shimmer. `loading` (a check or a page loading, not agent + * work) always draws the turning ring. `cv-spinner` turns the ring on the shared clock. */ +export const Spinner = ({ + size = 16, + className, + style, + strokeWidth = 1.5, + loading = false, +}: CvIconProps & { loading?: boolean }) => ( ); diff --git a/webviews/src/agent-session/acpmux/conversation/spinnerTurns.test.tsx b/webviews/src/agent-session/acpmux/conversation/spinnerTurns.test.tsx index 78fe2584be00..6f3c7e405b06 100644 --- a/webviews/src/agent-session/acpmux/conversation/spinnerTurns.test.tsx +++ b/webviews/src/agent-session/acpmux/conversation/spinnerTurns.test.tsx @@ -1,22 +1,17 @@ // The transcript's running rows (compaction, tool groups, MCP calls, a message card, command and -// shell rows) draw `Spinner`, and it turns: Leo 2026-10-20 ("why do these things not spin?") saw -// "Compacting the conversation…" with a ring that never moved, because no rule animated it. It runs -// the shared `cmux-spin` keyframes (ui/spinnerClock.ts keeps every page spinner on one phase), -// only with motion allowed and not under the static status look. +// shell rows) draw `Spinner`: Leo 2026-10-20 ("why do these things not spin?") saw "Compacting the +// conversation…" with a ring that never moved, because no rule animated it. Agent work marks itself +// `data-working` so the working look (WORKING-LOOK, conversation.css) can draw it; the loops +// themselves are checked in a browser (test/static-status-loops.test.ts). import { expect, test } from "bun:test"; -import { readFileSync } from "node:fs"; -import { join } from "node:path"; import { renderToStaticMarkup } from "react-dom/server"; import { CompactionNote } from "./CompactionNote"; import { Spinner } from "./icons"; -const css = readFileSync(join(import.meta.dir, "conversation.css"), "utf8") - .replace(/\/\*[\s\S]*?\*\//g, "") - .replace(/\s+/g, " "); - test("a running compaction draws the transcript spinner", () => { const html = renderToStaticMarkup(); expect(html).toContain('class="cv-spinner"'); + expect(html).toContain("data-working"); }); test("a spinner keeps its own class next to the caller's", () => { @@ -24,10 +19,8 @@ test("a spinner keeps its own class next to the caller's", () => { expect(html).toContain('class="cv-spinner acpmux-harness-spinner"'); }); -test("the transcript spinner turns on the shared clock, and holds still without motion", () => { - const rule = css.match( - /@media \(prefers-reduced-motion: no-preference\) \{ :root:not\(\[data-status-indicators="static"\]\) \.cv-spinner \{([^}]*)\}/, - ); - expect(rule).not.toBeNull(); - expect(rule![1]).toContain("animation: cmux-spin var(--cmux-spinner-period, 900ms) linear infinite"); +test("a loading spinner (a harness check) never takes the working dot", () => { + const html = renderToStaticMarkup(); + expect(html).not.toContain("data-working"); + expect(html).not.toContain("cv-spinner__dot"); }); diff --git a/webviews/src/agent-session/shared/theme.ts b/webviews/src/agent-session/shared/theme.ts index ffd79c77ee44..7154300aa654 100644 --- a/webviews/src/agent-session/shared/theme.ts +++ b/webviews/src/agent-session/shared/theme.ts @@ -28,6 +28,7 @@ const cssVariables: Record = { turnActionsPlacement: null, motion: null, statusIndicators: null, + workingLook: null, }; const motionVariables = { @@ -55,6 +56,9 @@ export function applyAgentTheme(theme: AgentSessionTheme): void { // Static status indicators (or Reduce Motion): the stylesheets stop their loops under it. if (theme.statusIndicators) root.dataset.statusIndicators = theme.statusIndicators; else delete root.dataset.statusIndicators; + // How agent work shows on running rows (conversation.css WORKING-LOOK). + if (theme.workingLook) root.dataset.workingLook = theme.workingLook; + else delete root.dataset.workingLook; for (const [key, variable] of Object.entries(cssVariables) as Array<[keyof AgentSessionTheme, string | null]>) { if (!variable) { continue; diff --git a/webviews/src/agent-session/shared/types.ts b/webviews/src/agent-session/shared/types.ts index 01a203988801..c372a021b0be 100644 --- a/webviews/src/agent-session/shared/types.ts +++ b/webviews/src/agent-session/shared/types.ts @@ -65,6 +65,9 @@ export type AgentSessionTheme = { /// `data-status-indicators` on the root; under `static` no status loop runs and color-only /// marks draw a shape each. Absent means the stylesheet defaults (animated). statusIndicators?: "animated" | "static"; + /// How agent work shows (Debug Settings `status.working`, CmuxNextDesign StatusWorkingLook): sets + /// `data-working-look` on the root. Absent means the stylesheet default, the breathing dot. + workingLook?: "breathe" | "shimmer" | "spinner"; }; export type AppContext = { diff --git a/webviews/src/pages/shared/desktop.css b/webviews/src/pages/shared/desktop.css index 904a69488f6b..206834aa0e92 100644 --- a/webviews/src/pages/shared/desktop.css +++ b/webviews/src/pages/shared/desktop.css @@ -458,9 +458,30 @@ input[type="submit"] { infinite` inside `prefers-reduced-motion: no-preference`, never their own keyframes. */ :root { --cmux-spinner-period: 900ms; + --cmux-breath-period: 2.4s; } @keyframes cmux-spin { to { transform: rotate(360deg); } } +/* WORKING-LOOK: agent work's calm mark, a dot that breathes (opacity only), one breath per + `--cmux-breath-period`, on the same shared clock, so many working rows breathe as one. */ +@keyframes cmux-breathe { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.3; + } +} +/* WORKING-LOOK "shimmer": a light band crosses the words of a working row, once per breath. */ +@keyframes cmux-shimmer { + from { + background-position: 100% 0; + } + to { + background-position: -50% 0; + } +} diff --git a/webviews/src/ui/spinnerClock.ts b/webviews/src/ui/spinnerClock.ts index 12d44ad3a53a..25cd268ab162 100644 --- a/webviews/src/ui/spinnerClock.ts +++ b/webviews/src/ui/spinnerClock.ts @@ -6,6 +6,9 @@ /** The keyframes every page spinner runs (desktop.css). */ export const SPINNER_KEYFRAMES = "cmux-spin"; +/** Every shared loop the clock keeps in phase: the spinner, and agent work's breathing dot and + * shimmer (WORKING-LOOK, desktop.css), so many working rows move as one. */ +export const SHARED_LOOP_KEYFRAMES: ReadonlySet = new Set([SPINNER_KEYFRAMES, "cmux-breathe", "cmux-shimmer"]); /** The start time (document timeline ms) a loop of `period` ms must have so that its phase at * `timelineNow` is (Unix ms) mod `period`. `timeOrigin` is `performance.timeOrigin`, the Unix ms at @@ -24,9 +27,9 @@ export type SpinnerAnimation = { effect: { getTiming(): { duration?: number | string | CSSNumericValue } } | null; }; -/** Moves `animation` onto the shared phase when it runs the spinner keyframes; true if it did. */ +/** Moves `animation` onto the shared phase when it runs shared loop keyframes; true if it did. */ export function alignSpinner(animation: SpinnerAnimation, timelineNow: number, timeOrigin: number): boolean { - if (animation.animationName !== SPINNER_KEYFRAMES) return false; + if (!animation.animationName || !SHARED_LOOP_KEYFRAMES.has(animation.animationName)) return false; const duration = animation.effect?.getTiming().duration; if (typeof duration !== "number" || !(duration > 0)) return false; animation.startTime = spinnerStartTime(timelineNow, timeOrigin, duration); @@ -43,7 +46,7 @@ export function installSpinnerClock(doc: Document = document): void { doc.addEventListener( "animationstart", (event) => { - if (event.animationName !== SPINNER_KEYFRAMES || !(event.target instanceof Element)) return; + if (!SHARED_LOOP_KEYFRAMES.has(event.animationName) || !(event.target instanceof Element)) return; const now = doc.timeline?.currentTime; const origin = doc.defaultView?.performance.timeOrigin; if (typeof now !== "number" || typeof origin !== "number") return; diff --git a/webviews/test/static-status-loops.test.ts b/webviews/test/static-status-loops.test.ts index b96e0017351f..f8a61ccdec80 100644 --- a/webviews/test/static-status-loops.test.ts +++ b/webviews/test/static-status-loops.test.ts @@ -42,6 +42,11 @@ await requireBrowserLane("static-status-loops.test.ts", async () => { // The script the app installs at document start in every cmux web view (WebTheme.swift). const bootstrap = readWebThemeBootstrap(); + // A running transcript row's working mark (Spinner, WORKING-LOOK) with its words. + const working = + `
` + + `` + + `Compacting
`; const paneLoops = `` + `` + @@ -51,6 +56,7 @@ await requireBrowserLane("static-status-loops.test.ts", async () => { `` + `
` + `` + + working + `
` + `writing`; const diffLoops = `
Loading
`; @@ -116,6 +122,24 @@ await requireBrowserLane("static-status-loops.test.ts", async () => { expect(running.some((loop) => loop.startsWith("cv-plan__mark") && loop.endsWith("cmux-spin"))).toBe(true); // The running rows' spinner (compaction, tool runs) too: it once had no rule and never turned. expect(running.some((loop) => loop.startsWith("cv-spinner") && loop.endsWith("cmux-spin"))).toBe(true); + // Agent work breathes by default (WORKING-LOOK). + expect(running.some((loop) => loop.startsWith("cv-spinner__dot") && loop.endsWith("cmux-breathe"))).toBe(true); + }); + + test("each working look runs its own loop on agent work, and loading keeps the spinner", async () => { + const looks: Record = {}; + for (const look of ["breathe", "shimmer", "spinner"]) { + const p = await page(`${desktop}${pane}`, `${working}`, "no-preference"); + await p.evaluate((value) => { + document.documentElement.dataset.workingLook = value; + return new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); + }, look); + looks[look] = (await loops(p)).map((loop) => loop.replace(/\s+/g, " ")).sort(); + await p.close(); + } + expect(looks.breathe).toEqual(["cv-spinner cmux-spin", "cv-spinner__dot cmux-breathe"]); + expect(looks.shimmer).toEqual(["cv-spinner cmux-spin", "cv-tool__text cmux-shimmer"]); + expect(looks.spinner).toEqual(["cv-spinner cmux-spin", "cv-spinner cmux-spin"]); }); test("Static status indicators stop every pane loop", async () => {