Skip to content
Closed
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 @@ -34,6 +34,7 @@ public struct TabItemMapping {
status: status(tab)
)
item.isNewTabPage = isNewTabPage
item.iconIsKind = !isNewTabPage // the New Tab page's icon is its only mark
if busy.state.replacesTabIcon { item.indicator = busy.state }
item.busyStyle = busy.style
if item.status == .needsInput { item.blockedKind = StatusMapping.shared.blockedKind(tab) }
Expand All @@ -47,7 +48,7 @@ public struct TabItemMapping {
/// mark, page icon, favicon). Callers that set a derived icon after ``item(_:fallbackTitle:isNewTabPage:)``
/// apply it again last. A busy indicator still covers it while the tab loads.
public func applyUserIcon(_ tab: TabModel, to item: inout StripTabItem, asset: (String) -> TabImage? = { _ in nil }) {
if let icon = TabUserIcon.shared.icon(tab.userIcon, asset: asset) { item.icon = icon }
if let icon = TabUserIcon.shared.icon(tab.userIcon, asset: asset) { (item.icon, item.iconIsKind) = (icon, false) }
}

/// A tab's kind icon from the cmux icon registry. A terminal whose agent session is live,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/// How a workspace row shows what waits in it (Debug Settings `sidebar.rowBadge`). Leo and
/// Lawrence 2026-10-14: no count numbers on NEXT rows. `corner` follows T3 Code's thread list:
/// the row leads with its harness logo or kind icon, and one small badge on that icon's corner says
/// the most urgent state; the count reads in the hover card. `stack` is Leo's alternative, one
/// glyph per pending item overlapping in the trailing slot; `trailing` keeps the count badge and
/// the trailing status glyph (the look before); `status` drops the kind icon and badge for one
/// status icon on a side, and tabs follow it (`StatusIndicatorConfig.statusSide`). Leo 2026-10-15:
/// `status` is the default.
public nonisolated enum SidebarRowBadgeStyle: String, Sendable, CaseIterable, Hashable, TunableChoice {
/// One badge on the leading icon's bottom-right corner.
case corner
/// Overlapping glyphs in the trailing slot, one per pending item, up to `SidebarRowBadgeStackCap`.
case stack
/// The count badge and the trailing status glyph.
case trailing
/// No kind icon and no badge: one status icon (the most urgent state) on the side
/// `SidebarStatusIconSide` names; tabs draw theirs the same way (Leo and Lawrence 2026-10-14).
case status

public var tunableTitle: String {
switch self {
case .corner: "Corner badge on the icon"
case .stack: "Glyph stack (one per item)"
case .trailing: "Count and trailing glyph (before)"
case .status: "Status icon on the left or right"
}
}

public static let tunable = Tunable<SidebarRowBadgeStyle>.choice(
"sidebar.rowBadge", .sidebar, "Row badge",
help: "How a workspace row shows unread items and agent states: a badge on the leading icon's corner, a stack of glyphs, the count with a trailing glyph, or one status icon on the side Status Icon Side names, on rows and tabs alike.",
default: .status, code: "SidebarRowBadgeStyle.tunable")
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// The side of a workspace row and a tab its status icon sits on when the row badge style is
/// `status` (Debug Settings `sidebar.statusIconSide`; Leo and Lawrence 2026-10-14). A Debug choice
/// until it is promoted to Settings.
public nonisolated enum SidebarStatusIconSide: String, Hashable, Sendable, CaseIterable, TunableChoice {
case left
case right

public var tunableTitle: String {
switch self {
case .left: "Left"
case .right: "Right"
}
}

public static let tunable = Tunable<SidebarStatusIconSide>.choice(
"sidebar.statusIconSide", .sidebar, "Status icon side",
help: "With the Status icon row badge, which side of a sidebar row and a tab shows its status icon (working, needs input, done, error).",
default: .left, code: "SidebarStatusIconSide.tunable")
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,11 +27,15 @@ public nonisolated struct StatusIndicatorConfig: Hashable, Sendable {
public var terminalFontFamily: String?
/// The status icon set (Debug Settings `status.iconSet`, cx-kxa2).
public var iconSet: StatusIconSet
/// The side tabs draw their status mark on when rows show a status icon (`SidebarRowBadgeStyle.status`,
/// Debug Settings `sidebar.statusIconSide`); nil keeps the mark on the icon's corner.
public var statusSide: SidebarStatusIconSide?

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) {
terminalFontFamily: String? = nil, iconSet: StatusIconSet = .current,
statusSide: SidebarStatusIconSide? = nil) {
self.settings = settings
self.styleOverride = styleOverride
self.arcLength = arcLength
Expand All @@ -44,6 +48,7 @@ public nonisolated struct StatusIndicatorConfig: Hashable, Sendable {
self.pulsePeriod = pulsePeriod
self.terminalFontFamily = terminalFontFamily
self.iconSet = iconSet
self.statusSide = statusSide
}

/// The style for a report that asked for `hint`: the Debug Settings
Expand All @@ -66,7 +71,8 @@ public nonisolated struct StatusIndicatorConfig: Hashable, Sendable {
spinnerPeriod: Motion.period(.spinner),
pulsePeriod: Motion.period(.pulse),
terminalFontFamily: DesignSettings.shared.terminalFontFamily,
iconSet: StatusIconSet.tunable.value)
iconSet: StatusIconSet.tunable.value,
statusSide: SidebarRowBadgeStyle.tunable.value == .status ? SidebarStatusIconSide.tunable.value : nil)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,6 @@ public nonisolated enum SidebarTunables {
default: false, code: "SidebarTunables.rowBadgePreview")

public static var all: [TunableDescriptor] {
[groupEdgeFraction, groupExitFraction, sectionTopFraction, workspaceOntoStart, workspaceOntoEnd].map(\.descriptor) + [agentMark.descriptor, presenceDots.descriptor, SidebarRowBadgeStyle.tunable.descriptor, SidebarRowBadgeStackCap.tunable.descriptor, rowBadgePreview.descriptor, NotificationBadgeLook.tunable.descriptor, NotificationIconLook.tunable.descriptor, updateCardButton.descriptor, SidebarChatsDesign.tunable.descriptor] + SidebarSectionTunables.all
[groupEdgeFraction, groupExitFraction, sectionTopFraction, workspaceOntoStart, workspaceOntoEnd].map(\.descriptor) + [agentMark.descriptor, presenceDots.descriptor, SidebarRowBadgeStyle.tunable.descriptor, SidebarStatusIconSide.tunable.descriptor, SidebarRowBadgeStackCap.tunable.descriptor, rowBadgePreview.descriptor, NotificationBadgeLook.tunable.descriptor, NotificationIconLook.tunable.descriptor, updateCardButton.descriptor, SidebarChatsDesign.tunable.descriptor] + SidebarSectionTunables.all
}
}
Original file line number Diff line number Diff line change
@@ -1,33 +1,5 @@
public import CmuxNextDesign

/// How a workspace row shows what waits in it (Debug Settings `sidebar.rowBadge`). Leo and
/// Lawrence 2026-10-14: no count numbers on NEXT rows. The default follows T3 Code's thread list:
/// the row leads with its harness logo or kind icon, and one small badge on that icon's corner says
/// the most urgent state; the count reads in the hover card. `stack` is Leo's alternative, one
/// glyph per pending item overlapping in the trailing slot; `trailing` keeps the count badge and
/// the trailing status glyph (the look before).
public nonisolated enum SidebarRowBadgeStyle: String, Sendable, CaseIterable, Hashable, TunableChoice {
/// One badge on the leading icon's bottom-right corner.
case corner
/// Overlapping glyphs in the trailing slot, one per pending item, up to `SidebarRowBadgeStackCap`.
case stack
/// The count badge and the trailing status glyph.
case trailing

public var tunableTitle: String {
switch self {
case .corner: "Corner badge on the icon"
case .stack: "Glyph stack (one per item)"
case .trailing: "Count and trailing glyph (before)"
}
}

public static let tunable = Tunable<SidebarRowBadgeStyle>.choice(
"sidebar.rowBadge", .sidebar, "Row badge",
help: "How a workspace row shows unread items and agent states: a badge on the leading icon's corner, a stack of glyphs, or the count with a trailing glyph.",
default: .corner, code: "SidebarRowBadgeStyle.tunable")
}

/// A row's pending items, most urgent first: a failure, then a permission (or sign-in) request,
/// then a question, then work in progress, then each unread item (done). The corner badge draws
/// the first; the stack draws up to `stackLimit` (`SidebarRowBadgeStackCap`) and leaves the rest to
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import AppKit
import CmuxNextDesign
import Observation

/// The sidebar tunables workspace rows draw from (agent mark, presence dots,
/// row badge, stack size),
/// row badge, stack size, the status icon's side),
/// observed once for every row. Each `WorkspaceRowView.configure` used to arm
/// its own observation, so every status tick left one more registration
/// behind; now a change redraws the live rows from one registration.
@MainActor
final class SidebarTunablesWatch {
static let shared = SidebarTunablesWatch()

typealias Values = (agentMark: SidebarAgentMarkVariant, presenceDots: Bool, rowBadge: SidebarRowBadgeStyle, stackCap: SidebarRowBadgeStackCap)
typealias Values = (agentMark: SidebarAgentMarkVariant, presenceDots: Bool, rowBadge: SidebarRowBadgeStyle, stackCap: SidebarRowBadgeStackCap,
statusSide: SidebarStatusIconSide)

private let rows = NSHashTable<WorkspaceRowView>.weakObjects()
private var current: Values?
Expand All @@ -21,7 +23,8 @@ final class SidebarTunablesWatch {
if let current { return current }
let values = withObservationTracking {
(SidebarTunables.agentMark.value, SidebarTunables.presenceDots.value, SidebarRowBadgeStyle.tunable.value,
SidebarRowBadgeStackCap.tunable.value)
SidebarRowBadgeStackCap.tunable.value,
SidebarStatusIconSide.tunable.value)
} onChange: {
Task { @MainActor in SidebarTunablesWatch.shared.changed() }
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import Foundation
/// shows the dim time since the last change.
extension WorkspaceRowView {
/// Applies `style` after the rest of `configure`: the row badge and the trailing time. `trailing` leaves the row as it was.
/// `status` draws the state as one status icon (`configure` set it): no badge, the time on the right.
func configureBadges(_ ws: SidebarWorkspace, shown: WorkspaceRowContent, style: SidebarRowBadgeStyle,
stackLimit: Int = SidebarRowBadgeStackCap.tunable.defaultValue.limit) {
let badges = WorkspaceRowBadges(unread: ws.unread, activity: shown.activity, stackLimit: stackLimit)
activity.themedToolTip = nil
guard style != .trailing, !isShowingPlaceholder else {
rowBadge.configure([], look: .corner)
return
Expand All @@ -20,6 +22,11 @@ extension WorkspaceRowView {
timeText = time
timeLabel.stringValue = time
}
if style == .status {
rowBadge.configure([], look: .corner)
activity.themedToolTip = Self.badgeSummary(badges, stacked: false)
return
}
let corner = style == .corner
rowBadge.configure(corner ? badges.first.map { [$0] } ?? [] : badges.stacked, look: corner ? .corner : .stack)
rowBadge.themedToolTip = Self.badgeSummary(badges, stacked: !corner)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,28 @@ import AppKit
import CmuxNextDesign

extension WorkspaceRowView {
/// An AppKit label cell draws its text this far inside its frame; the
/// marquee label draws at its edge, so it sits this much further in.
static let labelInset = Metrics.space1
/// Room for a two-digit count, so 9 to 10 tabs moves nothing else.
static let countWidth: CGFloat = 18
/// The PR badge never takes more than this from the title.
static let badgeMaxWidth: CGFloat = 72

/// The state the activity indicator draws: `activity`, or with the `.status` row badge the
/// row's most urgent state, a done mark for unread work included.
func rowStatus(_ ws: SidebarWorkspace, activity: StatusIndicatorState) -> StatusIndicatorState {
statusSide == nil ? activity : WorkspaceRowBadges(unread: ws.unread, activity: activity).first ?? .idle
}

/// Places a status icon on the left (the `.status` row badge) in a fixed slot at `x`, shown or
/// not, so every row's title starts on one column, and returns where the icon or title starts.
func layoutLeadingStatus(at x: CGFloat, centerY: CGFloat) -> CGFloat {
guard statusSide == .left else { return x }
activity.frame = statusGlyphFrame(trailing: x + SidebarStyle.statusGlyphSize, centerY: centerY)
return x + SidebarStyle.statusGlyphSize + Metrics.space2
}

/// The status mark's frame (the shared indicator, or the agent mark standing in for it) ending
/// at `trailing`: as tall as the unread badge, centered on `centerY`, the line the badge and
/// the row's other trailing marks share (a compact row's middle, a one-line row's and a
Expand Down
Loading
Loading