aboutsummaryrefslogtreecommitdiff
path: root/menubar/panel.swift
diff options
context:
space:
mode:
Diffstat (limited to 'menubar/panel.swift')
-rw-r--r--menubar/panel.swift435
1 files changed, 312 insertions, 123 deletions
diff --git a/menubar/panel.swift b/menubar/panel.swift
index ad5ee99..357ff91 100644
--- a/menubar/panel.swift
+++ b/menubar/panel.swift
@@ -2,10 +2,12 @@
//
// - Never takes focus: a non-activating panel, so typing stays in the
// terminal. Visible on every Space and over full-screen apps.
-// - Modes: off, on, auto (shown while something is blocked or done).
-// The global shortcut shows or hides it on top of the mode.
-// - Compact: blocked and done agents in full; working and idle as one
-// summary line. The menu keeps the full list.
+// - Modes: off, on ("Always"), auto ("When agents are waiting"). The global
+// shortcut shows or hides it on top of the mode.
+// - Layout: compact (waiting agents in full, working and idle as one summary
+// line); in "Always" mode a toggle switches to the full list.
+// - Always dark: herdr's mocha palette with fixed colors on a dark tint, so
+// text reads the same over any desktop.
import AppKit
@@ -54,99 +56,79 @@ struct PanelVisibility: Equatable {
}
}
-/// Run the visibility rules against expectations; returns failure messages.
-func panelSelftest() -> [String] {
- var failures: [String] = []
- func check(_ condition: Bool, _ message: String) {
- if !condition { failures.append(message) }
- }
-
- var auto = PanelVisibility(mode: .auto)
- check(!auto.visible, "auto: hidden with nothing waiting")
- auto.update(waiting: ["a"])
- check(auto.visible, "auto: shown when an agent waits")
- auto.toggle()
- check(!auto.visible, "auto: shortcut hides")
- auto.update(waiting: ["a"])
- check(!auto.visible, "auto: stays hidden for the same waiting agents")
- auto.update(waiting: ["a", "b"])
- check(auto.visible, "auto: a newly waiting agent shows it again")
- auto.update(waiting: [])
- check(!auto.visible, "auto: hides when nothing waits")
- auto.toggle()
- check(auto.visible, "auto: shortcut shows it with nothing waiting")
- auto.update(waiting: [])
- check(auto.visible, "auto: a manual show stays")
- auto.toggle()
- check(!auto.visible, "auto: shortcut hides again")
-
- var off = PanelVisibility(mode: .off)
- off.update(waiting: ["a"])
- check(!off.visible, "off: hidden even when an agent waits")
- off.toggle()
- check(off.visible, "off: shortcut shows")
- off.toggle()
- off.update(waiting: ["a", "b"])
- check(!off.visible, "off: a new waiting agent does not show it")
-
- var on = PanelVisibility(mode: .on)
- check(on.visible, "on: shown with nothing waiting")
- on.toggle()
- check(!on.visible, "on: shortcut hides")
- on.update(waiting: ["a"])
- check(on.visible, "on: a newly waiting agent shows it again")
-
- var changed = PanelVisibility(mode: .auto, forced: false, waiting: ["a"])
- changed.setMode(.on)
- check(changed.visible && changed.forced == nil, "mode change clears the shortcut override")
- return failures
+/// The full list is offered only in "Always" mode; otherwise compact.
+func panelShowsFull(mode: PanelMode, prefersFull: Bool) -> Bool {
+ mode == .on && prefersFull
}
-// MARK: compact content (pure, printed by --print-panel)
+// MARK: content (pure, printed by --print-panel)
struct PanelContent {
- static let maxRows = 8
+ static let maxCompactRows = 8
- let waiting: [Row]
+ /// Rows shown individually, in herdr's priority order.
+ let rows: [Row]
+ /// Index of the first row that is not waiting, when both kinds are shown:
+ /// a separator goes before it.
+ let separatorBefore: Int?
let overflow: Int
let working: Int
let idle: Int
-
- init(rows: [Row]) {
- let waitingRows = rows.filter(\.waiting)
- waiting = Array(waitingRows.prefix(Self.maxRows))
- overflow = max(0, waitingRows.count - Self.maxRows)
- working = rows.filter { $0.status == .working }.count
- idle = rows.filter { $0.status == .idle }.count
+ let full: Bool
+ /// Whether the panel offers the compact/full toggle ("Always" mode).
+ let canToggle: Bool
+
+ init(rows all: [Row], full: Bool = false, canToggle: Bool = false) {
+ let waiting = all.filter(\.waiting)
+ if full {
+ rows = all
+ overflow = 0
+ separatorBefore = (!waiting.isEmpty && waiting.count < all.count) ? waiting.count : nil
+ } else {
+ rows = Array(waiting.prefix(Self.maxCompactRows))
+ overflow = max(0, waiting.count - Self.maxCompactRows)
+ separatorBefore = nil
+ }
+ working = all.filter { $0.status == .working }.count
+ idle = all.filter { $0.status == .idle }.count
+ self.full = full
+ self.canToggle = canToggle
}
- /// "◐ 2 working · ○ 15 idle", or "No agents".
+ private var hasWaiting: Bool { rows.contains(where: \.waiting) }
+
+ /// "◐ 2 working · ○ 15 idle", with "Nothing waiting" first when no
+ /// agent waits.
var summary: NSAttributedString {
let font = NSFont.systemFont(ofSize: 11)
let text = NSMutableAttributedString()
+ let separator = NSAttributedString(
+ string: " · ", attributes: [.font: font, .foregroundColor: mochaOverlay])
+ if !hasWaiting {
+ text.append(NSAttributedString(
+ string: working + idle > 0 ? "Nothing waiting" : "No agents",
+ attributes: [.font: font, .foregroundColor: mochaSubtext]))
+ }
for (status, count) in [(Status.working, working), (.idle, idle)] where count > 0 {
- if text.length > 0 {
- text.append(NSAttributedString(
- string: " · ", attributes: [.font: font, .foregroundColor: NSColor.tertiaryLabelColor]))
- }
+ if text.length > 0 { text.append(separator) }
text.append(NSAttributedString(
string: "\(status.symbol) ", attributes: [.font: font, .foregroundColor: status.color]))
text.append(NSAttributedString(
- string: "\(count) \(status.rawValue)",
- attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor]))
- }
- if waiting.isEmpty {
- let lead = text.length > 0 ? "Nothing waiting · " : "No agents waiting"
- text.insert(NSAttributedString(
- string: lead, attributes: [.font: font, .foregroundColor: NSColor.secondaryLabelColor]), at: 0)
+ string: "\(count) \(status.rawValue)", attributes: [.font: font, .foregroundColor: mochaSubtext]))
}
return text
}
+ var toggleLabel: String { full ? "Show less ▴" : "Show all ▾" }
+
var plainText: String {
- var lines = waiting.map { "\($0.status.symbol) \($0.status.rawValue)\t\($0.label)\t\($0.subtitle)" }
+ var lines: [String] = []
+ for (index, row) in rows.enumerated() {
+ if index == separatorBefore { lines.append("---") }
+ lines.append("\(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)")
+ }
if overflow > 0 { lines.append("+\(overflow) more waiting") }
- lines.append(summary.string)
+ lines.append(summary.string + (canToggle ? "\t[\(toggleLabel)]" : ""))
return lines.joined(separator: "\n")
}
}
@@ -155,24 +137,73 @@ struct PanelContent {
private let panelWidth: CGFloat = 440
private let inset: CGFloat = 8
+/// herdr mocha base, laid over the blur so the panel stays dark on light
+/// desktops.
+private let panelTint = mocha(30, 30, 46, 0.72)
-/// One clickable agent row; a drag anywhere moves the panel instead.
-private final class PanelRowView: NSView {
- private let paneId: String
- private let onClick: (String) -> Void
+/// A click target that does not swallow drags: a drag moves the panel.
+private class ClickView: NSView {
+ var onClick: () -> Void = {}
private var dragged = false
- private var hovered = false { didSet { needsDisplay = true } }
+ var hovered = false { didSet { needsDisplay = true; hoverChanged() } }
+
+ func hoverChanged() {}
+
+ override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
+
+ override func updateTrackingAreas() {
+ super.updateTrackingAreas()
+ trackingAreas.forEach(removeTrackingArea)
+ addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways],
+ owner: self))
+ }
+
+ override func mouseEntered(with event: NSEvent) { hovered = true }
+ override func mouseExited(with event: NSEvent) { hovered = false }
+ override func mouseDown(with event: NSEvent) { dragged = false }
+ override func mouseDragged(with event: NSEvent) {
+ if !dragged {
+ dragged = true
+ window?.performDrag(with: event)
+ }
+ }
+
+ override func mouseUp(with event: NSEvent) {
+ if !dragged { onClick() }
+ }
+}
+
+/// One line, cut with "…" at the panel's edge instead of wrapping.
+private func singleLine(_ label: NSTextField) -> NSTextField {
+ label.maximumNumberOfLines = 1
+ label.usesSingleLineMode = true
+ label.cell?.wraps = false
+ label.cell?.truncatesLastVisibleLine = true
+ label.lineBreakMode = .byTruncatingTail
+ label.setContentCompressionResistancePriority(.defaultLow, for: .horizontal)
+ // An attributed string's own paragraph style wins over lineBreakMode.
+ let text = NSMutableAttributedString(attributedString: label.attributedStringValue)
+ if text.length > 0 {
+ let style = NSMutableParagraphStyle()
+ style.lineBreakMode = .byTruncatingTail
+ text.addAttribute(.paragraphStyle, value: style, range: NSRange(location: 0, length: text.length))
+ label.attributedStringValue = text
+ }
+ return label
+}
+
+/// One agent row: colored symbol and name, then a dim subtitle.
+private final class PanelRowView: ClickView {
init(row: Row, onClick: @escaping (String) -> Void) {
- paneId = row.paneId
- self.onClick = onClick
super.init(frame: .zero)
- let title = NSTextField(labelWithAttributedString: rowTitle(row))
- title.lineBreakMode = .byTruncatingTail
- let subtitle = NSTextField(labelWithString: row.subtitle)
- subtitle.font = .systemFont(ofSize: 11)
- subtitle.textColor = .secondaryLabelColor
- subtitle.lineBreakMode = .byTruncatingTail
+ self.onClick = { onClick(row.paneId) }
+ let title = singleLine(NSTextField(
+ labelWithAttributedString: rowTitle(row, primary: mochaText, secondary: mochaSubtext)))
+ let subtitleLabel = NSTextField(labelWithString: row.subtitle)
+ subtitleLabel.font = .systemFont(ofSize: 11)
+ subtitleLabel.textColor = mochaSubtext
+ let subtitle = singleLine(subtitleLabel)
let stack = NSStackView(views: [title, subtitle])
stack.orientation = .vertical
stack.alignment = .leading
@@ -181,54 +212,59 @@ private final class PanelRowView: NSView {
addSubview(stack)
NSLayoutConstraint.activate([
stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8),
- stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8),
+ stack.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -8),
stack.topAnchor.constraint(equalTo: topAnchor, constant: 5),
stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5),
+ title.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor),
+ subtitle.widthAnchor.constraint(lessThanOrEqualTo: stack.widthAnchor),
])
toolTip = row.name.isEmpty ? nil : row.name
}
required init?(coder: NSCoder) { fatalError("not used") }
- override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true }
-
- override func updateTrackingAreas() {
- super.updateTrackingAreas()
- trackingAreas.forEach(removeTrackingArea)
- addTrackingArea(NSTrackingArea(rect: bounds, options: [.mouseEnteredAndExited, .activeAlways],
- owner: self))
- }
-
- override func mouseEntered(with event: NSEvent) { hovered = true }
- override func mouseExited(with event: NSEvent) { hovered = false }
-
override func draw(_ dirtyRect: NSRect) {
guard hovered else { return }
NSColor.white.withAlphaComponent(0.08).setFill()
NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill()
}
+}
- override func mouseDown(with event: NSEvent) { dragged = false }
+/// "Show all ▾" / "Show less ▴".
+private final class ToggleView: ClickView {
+ private let label: NSTextField
- override func mouseDragged(with event: NSEvent) {
- if !dragged {
- dragged = true
- window?.performDrag(with: event)
- }
+ init(text: String, onClick: @escaping () -> Void) {
+ label = NSTextField(labelWithString: text)
+ super.init(frame: .zero)
+ self.onClick = onClick
+ label.font = .systemFont(ofSize: 11)
+ label.textColor = mochaSubtext
+ label.translatesAutoresizingMaskIntoConstraints = false
+ addSubview(label)
+ NSLayoutConstraint.activate([
+ label.leadingAnchor.constraint(equalTo: leadingAnchor),
+ label.trailingAnchor.constraint(equalTo: trailingAnchor),
+ label.topAnchor.constraint(equalTo: topAnchor),
+ label.bottomAnchor.constraint(equalTo: bottomAnchor),
+ ])
}
- override func mouseUp(with event: NSEvent) {
- if !dragged { onClick(paneId) }
- }
+ required init?(coder: NSCoder) { fatalError("not used") }
+
+ override func hoverChanged() { label.textColor = hovered ? mochaText : mochaSubtext }
}
final class PanelController {
private let panel: NSPanel
private let stack = NSStackView()
private let onClick: (String) -> Void
+ private let onToggleFull: () -> Void
+ private var rendered: String?
- init(onClick: @escaping (String) -> Void) {
+ init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, onToggleFull: @escaping () -> Void) {
self.onClick = onClick
+ self.onToggleFull = onToggleFull
panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: 60),
styleMask: [.nonactivatingPanel, .borderless],
backing: .buffered, defer: false)
@@ -240,7 +276,7 @@ final class PanelController {
panel.backgroundColor = .clear
panel.isOpaque = false
panel.hasShadow = true
- panel.appearance = NSAppearance(named: .vibrantDark)
+ panel.appearance = NSAppearance(named: .darkAqua)
let effect = NSVisualEffectView()
effect.material = .hudWindow
@@ -251,41 +287,59 @@ final class PanelController {
effect.layer?.masksToBounds = true
panel.contentView = effect
+ let tint = NSView()
+ tint.wantsLayer = true
+ tint.layer?.backgroundColor = panelTint.cgColor
+ tint.translatesAutoresizingMaskIntoConstraints = false
+ effect.addSubview(tint)
+
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 0
stack.translatesAutoresizingMaskIntoConstraints = false
effect.addSubview(stack)
NSLayoutConstraint.activate([
+ tint.leadingAnchor.constraint(equalTo: effect.leadingAnchor),
+ tint.trailingAnchor.constraint(equalTo: effect.trailingAnchor),
+ tint.topAnchor.constraint(equalTo: effect.topAnchor),
+ tint.bottomAnchor.constraint(equalTo: effect.bottomAnchor),
stack.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: inset),
stack.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -inset),
stack.topAnchor.constraint(equalTo: effect.topAnchor, constant: inset),
])
// Remember where the user put it; first time, top right of the screen.
- if !panel.setFrameUsingName("dingger.panel"), let screen = NSScreen.main {
+ if !persistFrame || !panel.setFrameUsingName("dingger.panel"), let screen = NSScreen.main {
let area = screen.visibleFrame
panel.setFrameTopLeftPoint(NSPoint(x: area.maxX - panelWidth - 16, y: area.maxY - 16))
}
- panel.setFrameAutosaveName("dingger.panel")
+ if persistFrame { panel.setFrameAutosaveName("dingger.panel") }
}
var isVisible: Bool { panel.isVisible }
- func render(_ content: PanelContent) {
- stack.arrangedSubviews.forEach { $0.removeFromSuperview() }
- for row in content.waiting {
- let view = PanelRowView(row: row, onClick: onClick)
- stack.addArrangedSubview(view)
- view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true
+ /// Rebuild the panel only when what it shows changes. Returns whether it
+ /// rebuilt.
+ @discardableResult
+ func render(_ content: PanelContent) -> Bool {
+ let text = content.plainText
+ guard text != rendered else { return false }
+ rendered = text
+
+ stack.arrangedSubviews.forEach { view in
+ stack.removeArrangedSubview(view)
+ view.removeFromSuperview()
+ }
+ for (index, row) in content.rows.enumerated() {
+ if index == content.separatorBefore { add(separatorLine()) }
+ add(PanelRowView(row: row, onClick: onClick), fullWidth: true)
}
if content.overflow > 0 {
- stack.addArrangedSubview(caption(NSAttributedString(
+ add(caption(NSAttributedString(
string: "+\(content.overflow) more waiting, see the menu bar",
- attributes: [.font: NSFont.systemFont(ofSize: 11),
- .foregroundColor: NSColor.secondaryLabelColor])))
+ attributes: [.font: NSFont.systemFont(ofSize: 11), .foregroundColor: mochaSubtext])))
}
- stack.addArrangedSubview(caption(content.summary))
+ add(summaryLine(content), fullWidth: true)
// Grow or shrink downward from the top edge.
stack.layoutSubtreeIfNeeded()
@@ -293,6 +347,30 @@ final class PanelController {
let top = panel.frame.maxY
panel.setFrame(NSRect(x: panel.frame.minX, y: top - height, width: panelWidth, height: height),
display: true)
+ return true
+ }
+
+ private func add(_ view: NSView, fullWidth: Bool = false) {
+ stack.addArrangedSubview(view)
+ if fullWidth { view.widthAnchor.constraint(equalTo: stack.widthAnchor).isActive = true }
+ }
+
+ private func separatorLine() -> NSView {
+ let line = NSView()
+ line.wantsLayer = true
+ line.layer?.backgroundColor = mochaOverlay.withAlphaComponent(0.4).cgColor
+ let box = NSView()
+ line.translatesAutoresizingMaskIntoConstraints = false
+ box.addSubview(line)
+ NSLayoutConstraint.activate([
+ line.heightAnchor.constraint(equalToConstant: 1),
+ line.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 8),
+ line.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -8),
+ line.topAnchor.constraint(equalTo: box.topAnchor, constant: 4),
+ line.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -4),
+ box.widthAnchor.constraint(equalToConstant: panelWidth - inset * 2),
+ ])
+ return box
}
private func caption(_ text: NSAttributedString) -> NSView {
@@ -309,6 +387,117 @@ final class PanelController {
return box
}
+ /// Summary on the left, the compact/full toggle on the right.
+ private func summaryLine(_ content: PanelContent) -> NSView {
+ let box = caption(content.summary)
+ guard content.canToggle else { return box }
+ let toggle = ToggleView(text: content.toggleLabel, onClick: onToggleFull)
+ toggle.translatesAutoresizingMaskIntoConstraints = false
+ box.addSubview(toggle)
+ NSLayoutConstraint.activate([
+ toggle.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -8),
+ toggle.centerYAnchor.constraint(equalTo: box.centerYAnchor, constant: 1),
+ ])
+ return box
+ }
+
func show() { panel.orderFrontRegardless() }
func hide() { panel.orderOut(nil) }
+
+ /// Debug: draw the panel offscreen over a white and a black backdrop,
+ /// side by side, as a PNG.
+ func snapshot(to path: String) -> Bool {
+ guard let view = panel.contentView else { return false }
+ view.layoutSubtreeIfNeeded()
+ guard let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false }
+ view.cacheDisplay(in: view.bounds, to: rep)
+ let size = view.bounds.size
+ let image = NSImage(size: NSSize(width: size.width * 2 + 20, height: size.height))
+ image.lockFocus()
+ NSColor.white.setFill()
+ NSRect(x: 0, y: 0, width: size.width + 10, height: size.height).fill()
+ NSColor.black.setFill()
+ NSRect(x: size.width + 10, y: 0, width: size.width + 10, height: size.height).fill()
+ // the blur does not render offscreen: approximate it with a mid gray
+ for x in [CGFloat(0), size.width + 20] {
+ NSColor(white: 0.5, alpha: 0.5).setFill()
+ NSBezierPath(roundedRect: NSRect(x: x, y: 0, width: size.width, height: size.height),
+ xRadius: 12, yRadius: 12).fill()
+ rep.draw(in: NSRect(x: x, y: 0, width: size.width, height: size.height))
+ }
+ image.unlockFocus()
+ guard let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff),
+ let png = bitmap.representation(using: .png, properties: [:]) else { return false }
+ return (try? png.write(to: URL(fileURLWithPath: path))) != nil
+ }
+}
+
+// MARK: selftest
+
+/// Run the panel rules against expectations; returns failure messages.
+func panelSelftest() -> [String] {
+ var failures: [String] = []
+ func check(_ condition: Bool, _ message: String) {
+ if !condition { failures.append(message) }
+ }
+
+ var auto = PanelVisibility(mode: .auto)
+ check(!auto.visible, "auto: hidden with nothing waiting")
+ auto.update(waiting: ["a"])
+ check(auto.visible, "auto: shown when an agent waits")
+ auto.toggle()
+ check(!auto.visible, "auto: shortcut hides")
+ auto.update(waiting: ["a"])
+ check(!auto.visible, "auto: stays hidden for the same waiting agents")
+ auto.update(waiting: ["a", "b"])
+ check(auto.visible, "auto: a newly waiting agent shows it again")
+ auto.update(waiting: [])
+ check(!auto.visible, "auto: hides when nothing waits")
+ auto.toggle()
+ check(auto.visible, "auto: shortcut shows it with nothing waiting")
+ auto.update(waiting: [])
+ check(auto.visible, "auto: a manual show stays")
+ auto.toggle()
+ check(!auto.visible, "auto: shortcut hides again")
+
+ var off = PanelVisibility(mode: .off)
+ off.update(waiting: ["a"])
+ check(!off.visible, "off: hidden even when an agent waits")
+ off.toggle()
+ check(off.visible, "off: shortcut shows")
+ off.toggle()
+ off.update(waiting: ["a", "b"])
+ check(!off.visible, "off: a new waiting agent does not show it")
+
+ var on = PanelVisibility(mode: .on)
+ check(on.visible, "on: shown with nothing waiting")
+ on.toggle()
+ check(!on.visible, "on: shortcut hides")
+ on.update(waiting: ["a"])
+ check(on.visible, "on: a newly waiting agent shows it again")
+
+ var changed = PanelVisibility(mode: .auto, forced: false, waiting: ["a"])
+ changed.setMode(.on)
+ check(changed.visible && changed.forced == nil, "mode change clears the shortcut override")
+
+ check(!panelShowsFull(mode: .auto, prefersFull: true), "layout: compact when agents are waiting")
+ check(!panelShowsFull(mode: .off, prefersFull: true), "layout: compact when shown by the shortcut in off")
+ check(panelShowsFull(mode: .on, prefersFull: true), "layout: full in always when chosen")
+ check(!panelShowsFull(mode: .on, prefersFull: false), "layout: compact in always by default")
+
+ func row(_ id: String, _ status: Status) -> Row {
+ Row(paneId: id, agent: "pi", name: id, status: status, session: "s", window: id, since: 1)
+ }
+ let many = (0..<10).map { row("w\($0)", .done) } + [row("x", .working), row("y", .idle)]
+ let compact = PanelContent(rows: many)
+ check(compact.rows.count == 8 && compact.overflow == 2, "compact: first 8 waiting, the rest counted")
+ check(compact.working == 1 && compact.idle == 1, "compact: working and idle summarized")
+ let full = PanelContent(rows: many, full: true, canToggle: true)
+ check(full.rows.count == 12 && full.overflow == 0, "full: every agent listed")
+ check(full.separatorBefore == 10, "full: separator between waiting and the rest")
+ check(PanelContent(rows: [row("x", .working)], full: true).separatorBefore == nil,
+ "full: no separator without waiting agents")
+ check(full.plainText.hasSuffix("[Show less ▴]") && !compact.plainText.contains("Show"),
+ "toggle offered only when allowed, labelled by layout")
+ return failures
}