// The floating panel: an always-on-top HUD with the agents waiting for you. // // - Never takes focus: a non-activating panel, so typing stays in the // terminal. Visible on every Space and over full-screen apps. // - On or off (the global shortcut and the menu toggle it; off stays off), // and while on: shown always, or only when agents are waiting. // - Layout: compact (waiting agents in full, working and idle as one summary // line); when shown always, 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 // MARK: visibility (pure, tested by --selftest) /// What the panel shows while it is on. enum PanelPolicy: String, CaseIterable { case always, whenWaiting var label: String { switch self { case .always: return "Always" case .whenWaiting: return "Only When Agents Are Waiting" } } } /// Two persistent settings and one fact: /// - enabled: the panel is on (the shortcut and the menu toggle it); /// - policy: while on, show it always or only when agents are waiting; /// - waiting: whether any agent is blocked or done. /// Off stays off: nothing shows the panel until it is turned back on. struct PanelVisibility: Equatable { static let peekSeconds: TimeInterval = 3 var enabled = true var policy: PanelPolicy = .whenWaiting var anyWaiting = false /// tmux stopped and its sessions can be restored. var restoreOffered = false /// Turning the panel on with nothing to show shows it briefly, so the /// shortcut visibly did something. var peekUntil: Date? func visible(at now: Date) -> Bool { guard enabled else { return false } if policy == .always || anyWaiting || restoreOffered { return true } return peekUntil.map { now < $0 } ?? false } mutating func toggle(at now: Date) { enabled.toggle() peekUntil = enabled ? now.addingTimeInterval(Self.peekSeconds) : nil } /// Settings from before the on/off split: a three-way mode. static func migrated(fromMode mode: String) -> PanelVisibility { switch mode { case "off": return PanelVisibility(enabled: false, policy: .whenWaiting) case "on": return PanelVisibility(enabled: true, policy: .always) default: return PanelVisibility(enabled: true, policy: .whenWaiting) } } } /// The full list is offered only when the panel shows always; otherwise /// compact. func panelShowsFull(policy: PanelPolicy, prefersFull: Bool) -> Bool { policy == .always && prefersFull } // MARK: content (pure, printed by --print-panel) struct PanelContent { static let maxCompactRows = 8 /// 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 let full: Bool /// Whether the panel offers the compact/full toggle (shown always). let canToggle: Bool /// Sessions to restore, shown first. let offer: RestoreOffer? /// A restore is running. let restoring: Bool init(rows all: [Row], full: Bool = false, canToggle: Bool = false, offer: RestoreOffer? = nil, restoring: Bool = false) { self.offer = offer self.restoring = restoring 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 } 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(separator) } text.append(NSAttributedString( string: "\(status.symbol) ", attributes: [.font: font, .foregroundColor: status.color])) text.append(NSAttributedString( string: "\(count) \(status.rawValue)", attributes: [.font: font, .foregroundColor: mochaSubtext])) } return text } var toggleLabel: String { full ? "Show less ▴" : "Show all ▾" } /// The offer row's title: what restore will do, or that it runs. var offerTitle: String? { guard let offer else { return nil } return restoring ? "Restoring \(offer.sessions.count) \(offer.sessions.count == 1 ? "session" : "sessions")…" : offer.title } /// The summary line is noise when tmux is gone and only the offer shows. var showsSummary: Bool { offer == nil || working + idle + rows.count > 0 } var plainText: String { var lines: [String] = [] if let offer, let offerTitle { lines.append("↺ \(offerTitle)\t\(offer.detail)" + (restoring ? "" : "\t[Not now]")) } 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") } if showsSummary { lines.append(summary.string + (canToggle ? "\t[\(toggleLabel)]" : "")) } return lines.joined(separator: "\n") } } // MARK: window 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) /// The panel's background: HUD blur under herdr's mocha base, rounded. func darkHUD() -> NSVisualEffectView { let effect = NSVisualEffectView() effect.material = .hudWindow effect.blendingMode = .behindWindow effect.state = .active effect.wantsLayer = true effect.layer?.cornerRadius = 12 effect.layer?.masksToBounds = true let tint = NSView() tint.wantsLayer = true tint.layer?.backgroundColor = panelTint.cgColor tint.translatesAutoresizingMaskIntoConstraints = false effect.addSubview(tint) 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), ]) return effect } /// A click target that does not swallow drags: a drag moves the panel. class ClickView: NSView { var onClick: () -> Void = {} private var dragged = false var hovered = false { didSet { needsDisplay = true; hoverChanged() } } func hoverChanged() {} override func acceptsFirstMouse(for event: NSEvent?) -> Bool { true } // Own every click inside, including over labels: a text field would // otherwise take the click (and count as a window-drag background). override func hitTest(_ point: NSPoint) -> NSView? { bounds.contains(convert(point, from: superview)) ? self : nil } // Dragging is done here (performDrag), not by the window server. override var mouseDownCanMoveWindow: Bool { false } 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. final class PanelRowView: ClickView { private let selected: Bool init(row: Row, title titleText: NSAttributedString? = nil, selected: Bool = false, onClick: @escaping (String) -> Void) { self.selected = selected super.init(frame: .zero) self.onClick = { onClick(row.paneId) } let title = singleLine(NSTextField( labelWithAttributedString: titleText ?? 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 stack.spacing = 1 stack.translatesAutoresizingMaskIntoConstraints = false addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: leadingAnchor, 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 draw(_ dirtyRect: NSRect) { guard hovered || selected else { return } NSColor.white.withAlphaComponent(selected ? 0.14 : 0.08).setFill() NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill() } } /// The restore offer: "↺ Restore 9 sessions · 22 agents", what it was. private final class OfferView: ClickView { init(title: String, detail: String, onClick: @escaping () -> Void) { super.init(frame: .zero) self.onClick = onClick let font = NSFont.menuFont(ofSize: 0) let text = NSMutableAttributedString(string: "↺ ", attributes: [ .font: NSFont.monospacedSystemFont(ofSize: font.pointSize, weight: .bold), .foregroundColor: mochaBlue, ]) text.append(NSAttributedString(string: title, attributes: [.font: font, .foregroundColor: mochaText])) let titleLabel = singleLine(NSTextField(labelWithAttributedString: text)) let detailLabel = NSTextField(labelWithString: detail) detailLabel.font = .systemFont(ofSize: 11) detailLabel.textColor = mochaSubtext let stack = NSStackView(views: [titleLabel, singleLine(detailLabel)]) stack.orientation = .vertical stack.alignment = .leading stack.spacing = 1 stack.translatesAutoresizingMaskIntoConstraints = false addSubview(stack) NSLayoutConstraint.activate([ stack.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 8), stack.trailingAnchor.constraint(lessThanOrEqualTo: trailingAnchor, constant: -8), stack.topAnchor.constraint(equalTo: topAnchor, constant: 5), stack.bottomAnchor.constraint(equalTo: bottomAnchor, constant: -5), ]) } required init?(coder: NSCoder) { fatalError("not used") } override func draw(_ dirtyRect: NSRect) { guard hovered else { return } NSColor.white.withAlphaComponent(0.08).setFill() NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill() } } /// "Show all ▾" / "Show less ▴". private final class ToggleView: ClickView { private let label: NSTextField 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), ]) } 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 let onRestore: () -> Void private let onDismissRestore: () -> Void private var rendered: String? init(persistFrame: Bool = true, onClick: @escaping (String) -> Void, onToggleFull: @escaping () -> Void, onRestore: @escaping () -> Void = {}, onDismissRestore: @escaping () -> Void = {}) { self.onClick = onClick self.onToggleFull = onToggleFull self.onRestore = onRestore self.onDismissRestore = onDismissRestore panel = NSPanel(contentRect: NSRect(x: 0, y: 0, width: panelWidth, height: 60), styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false) panel.isFloatingPanel = true panel.level = .floating panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary] panel.hidesOnDeactivate = false panel.isMovableByWindowBackground = true panel.backgroundColor = .clear panel.isOpaque = false panel.hasShadow = true panel.appearance = NSAppearance(named: .darkAqua) let effect = darkHUD() panel.contentView = effect stack.orientation = .vertical stack.alignment = .leading stack.spacing = 0 stack.translatesAutoresizingMaskIntoConstraints = false effect.addSubview(stack) NSLayoutConstraint.activate([ 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 !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)) } if persistFrame { panel.setFrameAutosaveName("dingger.panel") } } var isVisible: Bool { panel.isVisible } /// 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() } if let offer = content.offer, let title = content.offerTitle { add(offerLine(title: title, detail: offer.detail, restoring: content.restoring), fullWidth: true) if content.showsSummary || !content.rows.isEmpty { add(separatorLine()) } } 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 { add(caption(NSAttributedString( string: "+\(content.overflow) more waiting, see the menu bar", attributes: [.font: NSFont.systemFont(ofSize: 11), .foregroundColor: mochaSubtext]))) } if content.showsSummary { add(summaryLine(content), fullWidth: true) } // Grow or shrink downward from the top edge. stack.layoutSubtreeIfNeeded() let height = stack.fittingSize.height + inset * 2 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 { let label = NSTextField(labelWithAttributedString: text) let box = NSView() label.translatesAutoresizingMaskIntoConstraints = false box.addSubview(label) NSLayoutConstraint.activate([ label.leadingAnchor.constraint(equalTo: box.leadingAnchor, constant: 8), label.trailingAnchor.constraint(lessThanOrEqualTo: box.trailingAnchor, constant: -8), label.topAnchor.constraint(equalTo: box.topAnchor, constant: 6), label.bottomAnchor.constraint(equalTo: box.bottomAnchor, constant: -4), ]) return box } /// The offer row, with "Not now" beside it (a row takes every click /// inside it, so the link cannot be inside). private func offerLine(title: String, detail: String, restoring: Bool) -> NSView { let box = NSView() let row = OfferView(title: title, detail: detail, onClick: restoring ? {} : onRestore) row.translatesAutoresizingMaskIntoConstraints = false box.addSubview(row) var constraints = [ row.leadingAnchor.constraint(equalTo: box.leadingAnchor), row.topAnchor.constraint(equalTo: box.topAnchor), row.bottomAnchor.constraint(equalTo: box.bottomAnchor), ] if restoring { constraints.append(row.trailingAnchor.constraint(equalTo: box.trailingAnchor)) } else { let dismiss = ToggleView(text: "Not now", onClick: onDismissRestore) dismiss.translatesAutoresizingMaskIntoConstraints = false box.addSubview(dismiss) constraints += [ dismiss.trailingAnchor.constraint(equalTo: box.trailingAnchor, constant: -8), dismiss.centerYAnchor.constraint(equalTo: box.centerYAnchor), row.trailingAnchor.constraint(equalTo: dismiss.leadingAnchor, constant: -8), ] } NSLayoutConstraint.activate(constraints) 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) } func snapshot(to path: String) -> Bool { guard let view = panel.contentView else { return false } return snapshotView(view, to: path) } } /// Debug: draw a dark HUD view offscreen over a white and a black backdrop, /// side by side, as a PNG. func snapshotView(_ view: NSView, to path: String) -> Bool { 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) } } let t0 = Date(timeIntervalSince1970: 1_000) func at(_ seconds: TimeInterval) -> Date { t0.addingTimeInterval(seconds) } var panel = PanelVisibility() check(panel.enabled && panel.policy == .whenWaiting, "defaults: on, only when agents are waiting") check(!panel.visible(at: t0), "waiting policy: hidden with nothing waiting") panel.anyWaiting = true check(panel.visible(at: t0), "waiting policy: shown when an agent waits") panel.toggle(at: t0) check(!panel.visible(at: t0), "off: the shortcut hides it") panel.anyWaiting = false panel.anyWaiting = true check(!panel.visible(at: at(60)), "off: stays hidden when another agent starts waiting") panel.toggle(at: at(60)) check(panel.visible(at: at(60)), "on again: shown, an agent is waiting") panel.anyWaiting = false check(!panel.visible(at: at(70)), "waiting policy: hides when nothing waits") var peek = PanelVisibility(enabled: false, policy: .whenWaiting) peek.toggle(at: t0) check(peek.visible(at: at(1)), "turned on with nothing waiting: shown briefly") check(!peek.visible(at: at(PanelVisibility.peekSeconds + 0.1)), "the brief show ends after the peek") peek.toggle(at: at(10)) check(!peek.visible(at: at(10)) && peek.peekUntil == nil, "turned off: no peek") var always = PanelVisibility(enabled: true, policy: .always) check(always.visible(at: t0), "always: shown with nothing waiting") always.toggle(at: t0) always.anyWaiting = true check(!always.visible(at: at(5)), "always, off: an agent waiting does not show it") always.policy = .whenWaiting check(!always.enabled, "changing what it shows keeps it off") check(PanelVisibility.migrated(fromMode: "off") == PanelVisibility(enabled: false, policy: .whenWaiting), "migration: off") check(PanelVisibility.migrated(fromMode: "on") == PanelVisibility(enabled: true, policy: .always), "migration: always") check(PanelVisibility.migrated(fromMode: "auto") == PanelVisibility(enabled: true, policy: .whenWaiting), "migration: when waiting") var offered = PanelVisibility(enabled: true, policy: .whenWaiting) offered.restoreOffered = true check(offered.visible(at: t0), "a restore offer shows the panel") offered.toggle(at: t0) check(!offered.visible(at: at(10)), "off: a restore offer does not show it") check(!panelShowsFull(policy: .whenWaiting, prefersFull: true), "layout: compact when only waiting") check(panelShowsFull(policy: .always, prefersFull: true), "layout: full in always when chosen") check(!panelShowsFull(policy: .always, 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") let offer = RestoreOffer(saved_at: 0, sessions: [ .init(name: "a", windows: 2, panes: 3, agents: 2), .init(name: "b", windows: 1, panes: 1, agents: 0), ], agents: 2, interrupted: 1) let afterReboot = PanelContent(rows: [], offer: offer) check(afterReboot.plainText.hasPrefix("↺ Restore 2 sessions · 2 agents\t") && afterReboot.plainText.contains("1 was mid-turn") && afterReboot.plainText.hasSuffix("[Not now]"), "offer: first, with what it brings back and a way to dismiss it") check(!afterReboot.plainText.contains("No agents"), "offer: no empty summary when tmux is gone") let running = PanelContent(rows: [], offer: offer, restoring: true) check(running.plainText.hasPrefix("↺ Restoring 2 sessions…") && !running.plainText.contains("Not now"), "offer: while restoring, no dismiss") check(PanelContent(rows: many, offer: offer).plainText.contains("○ 1 idle"), "offer: the summary stays when there are agents") return failures }