aboutsummaryrefslogtreecommitdiff
path: root/menubar
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-27 14:12:28 +0200
committerIgor Soarez <igor@soarez.org>2026-10-27 14:12:28 +0200
commit50c433e82c368b9861a89deecb74e5f29fa50353 (patch)
treefe6d87b79387f6c53ef9bf615b7b530daf9a8d3a /menubar
parent9f3a567d4bb1b7b6e42a744936f993bcd437882d (diff)
mark as unread: make an idle agent done again, to come back to it
like "mark as unread" in mail: an agent you have seen (idle) becomes done again, last among done agents of its importance, with no sound or notification. it stays done until you come back to it: leave its tmux window and return, or jump to it. marked while you look at it, it holds until you look away (`held`); a new turn clears it. only idle agents can be marked. - daemon: `pane.mark_unread` on the hook socket; the watcher applies it after the tick's observations (mark_unread, advance's hold rule). - `dingger unread [--pane]`: refuses done, working, blocked, and panes without an agent; returns once the agent shows as done. - pi: /unread (refused while the agent works or is blocked). - panel: right-click an idle agent in the full list, "Mark as unread". - search: ⌘U or right-click marks the selected idle agent; the footer offers ⌘U when it applies; search stays open. tests: unit (hold, jump, new turn, idle only), e2e step 22 (queue position, hold, look away and back, jump, refusals, no sound, menu, the extension), pi-live in real pi 1.4 (a mark, not a turn: an unknown command would also end done), swift selftest (menu only for idle, footer).
Diffstat (limited to 'menubar')
-rw-r--r--menubar/main.swift12
-rw-r--r--menubar/model.swift9
-rw-r--r--menubar/panel.swift59
-rw-r--r--menubar/search.swift40
4 files changed, 107 insertions, 13 deletions
diff --git a/menubar/main.swift b/menubar/main.swift
index 1b6ce4f..135c099 100644
--- a/menubar/main.swift
+++ b/menubar/main.swift
@@ -88,10 +88,12 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
item.menu = menu
panel = PanelController(
onClick: { [weak self] paneId in self?.jump(to: paneId) },
+ onMarkUnread: { [weak self] paneId in self?.markUnread(paneId) },
onToggleFull: { [weak self] in self?.toggleFull() },
onRestore: { [weak self] in self?.restore(sessions: []) },
onDismissRestore: { [weak self] in self?.dismissRestore() })
- searchWindow = SearchController(onJump: { [weak self] paneId in self?.jump(to: paneId) })
+ searchWindow = SearchController(onJump: { [weak self] paneId in self?.jump(to: paneId) },
+ onMarkUnread: { [weak self] paneId in self?.markUnread(paneId) })
Settings.ensureFile()
reloadSettings()
refresh()
@@ -258,6 +260,14 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
}
+ private func markUnread(_ paneId: String) {
+ log("unread: \(paneId)")
+ DispatchQueue.global().async {
+ markAgentUnread(paneId)
+ DispatchQueue.main.async { self.refresh() }
+ }
+ }
+
// MARK: menu (rebuilt from fresh data every time it opens)
func menuNeedsUpdate(_ menu: NSMenu) {
diff --git a/menubar/model.swift b/menubar/model.swift
index 760ff31..513f9fb 100644
--- a/menubar/model.swift
+++ b/menubar/model.swift
@@ -94,6 +94,8 @@ struct Row: Equatable {
var location: String { "\(session) · \(window)" }
var waiting: Bool { status == .blocked || status == .done }
+ /// Only an agent you have seen can be marked unread (done again).
+ var canMarkUnread: Bool { status == .idle }
/// The row's title text: the agent's name, or its harness when unnamed.
var label: String {
@@ -286,6 +288,13 @@ func focus(_ paneId: String) {
if !result.ok { log("jump: dingger focus --pane \(paneId) failed: \(result.output)") }
}
+/// Mark an idle agent unread: `dingger unread` returns once it shows as
+/// done.
+func markAgentUnread(_ paneId: String) {
+ let result = runReporting(dinggerBinary, ["unread", "--pane", paneId])
+ log("unread: \(paneId) \(result.ok ? "marked" : "failed: \(result.output)")")
+}
+
// MARK: rendering
/// herdr-style summary: `×1 ✓3 ◐2` in priority order; `○` when every agent
diff --git a/menubar/panel.swift b/menubar/panel.swift
index 6619dc0..85cc81e 100644
--- a/menubar/panel.swift
+++ b/menubar/panel.swift
@@ -265,13 +265,37 @@ private func singleLine(_ label: NSTextField) -> NSTextField {
return label
}
-/// One agent row: colored symbol and name, then a dim subtitle.
+/// A menu item that runs a closure.
+final class ActionMenuItem: NSMenuItem {
+ private let handler: () -> Void
+
+ init(_ title: String, handler: @escaping () -> Void) {
+ self.handler = handler
+ super.init(title: title, action: #selector(run), keyEquivalent: "")
+ target = self
+ }
+
+ required init(coder: NSCoder) { fatalError("not used") }
+
+ @objc private func run() { handler() }
+}
+
+/// One agent row: colored symbol and name, then a dim subtitle. Right-click
+/// offers "Mark as unread" for an idle agent.
final class PanelRowView: ClickView {
private let selected: Bool
+ private let contextMenu: NSMenu?
init(row: Row, title titleText: NSAttributedString? = nil, selected: Bool = false,
- onClick: @escaping (String) -> Void) {
+ onClick: @escaping (String) -> Void, onMarkUnread: ((String) -> Void)? = nil) {
self.selected = selected
+ if let onMarkUnread, row.canMarkUnread {
+ let menu = NSMenu()
+ menu.addItem(ActionMenuItem("Mark as unread") { onMarkUnread(row.paneId) })
+ contextMenu = menu
+ } else {
+ contextMenu = nil
+ }
super.init(frame: .zero)
self.onClick = { onClick(row.paneId) }
let title = singleLine(NSTextField(
@@ -299,6 +323,8 @@ final class PanelRowView: ClickView {
required init?(coder: NSCoder) { fatalError("not used") }
+ override func menu(for event: NSEvent) -> NSMenu? { contextMenu }
+
override func draw(_ dirtyRect: NSRect) {
guard hovered || selected else { return }
NSColor.white.withAlphaComponent(selected ? 0.14 : 0.08).setFill()
@@ -372,14 +398,17 @@ final class PanelController {
private let panel: NSPanel
private let stack = NSStackView()
private let onClick: (String) -> Void
+ private let onMarkUnread: (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,
+ init(persistFrame: Bool = true, onClick: @escaping (String) -> Void,
+ onMarkUnread: @escaping (String) -> Void = { _ in }, onToggleFull: @escaping () -> Void,
onRestore: @escaping () -> Void = {}, onDismissRestore: @escaping () -> Void = {}) {
self.onClick = onClick
+ self.onMarkUnread = onMarkUnread
self.onToggleFull = onToggleFull
self.onRestore = onRestore
self.onDismissRestore = onDismissRestore
@@ -438,7 +467,7 @@ final class PanelController {
}
for (index, row) in content.rows.enumerated() {
if index == content.separatorBefore { add(separatorLine()) }
- add(PanelRowView(row: row, onClick: onClick), fullWidth: true)
+ add(PanelRowView(row: row, onClick: onClick, onMarkUnread: onMarkUnread), fullWidth: true)
}
if content.overflow > 0 {
add(caption(NSAttributedString(
@@ -582,6 +611,28 @@ func panelSelftest() -> [String] {
let t0 = Date(timeIntervalSince1970: 1_000)
func at(_ seconds: TimeInterval) -> Date { t0.addingTimeInterval(seconds) }
+ // Right-click: "Mark as unread" for idle agents only.
+ func agent(_ status: Status) -> Row {
+ Row(paneId: "%7", agent: "pi", name: "n", status: status, session: "s", window: "w", since: 1, path: "")
+ }
+ if let click = NSEvent.mouseEvent(with: .rightMouseDown, location: .zero, modifierFlags: [], timestamp: 0,
+ windowNumber: 0, context: nil, eventNumber: 0, clickCount: 1, pressure: 1) {
+ var marked: [String] = []
+ let idleMenu = PanelRowView(row: agent(.idle), onClick: { _ in },
+ onMarkUnread: { marked.append($0) }).menu(for: click)
+ check(idleMenu?.items.map(\.title) == ["Mark as unread"], "right-click: an idle agent offers Mark as unread")
+ if let item = idleMenu?.items.first, let target = item.target, let action = item.action {
+ _ = target.perform(action, with: item)
+ }
+ check(marked == ["%7"], "right-click: Mark as unread marks that agent")
+ for status in [Status.done, .working, .blocked] {
+ check(PanelRowView(row: agent(status), onClick: { _ in }, onMarkUnread: { _ in }).menu(for: click) == nil,
+ "right-click: nothing to offer for a \(status.rawValue) agent")
+ }
+ } else {
+ check(false, "right-click: could not make an event")
+ }
+
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")
diff --git a/menubar/search.swift b/menubar/search.swift
index 2bc044a..c62c612 100644
--- a/menubar/search.swift
+++ b/menubar/search.swift
@@ -157,9 +157,12 @@ struct SearchState: Equatable {
offset = max(0, min(offset, max(0, results.count - Self.visibleRows)))
}
+ var selectedRow: Row? { results.first { $0.row.paneId == selected }?.row }
+
var footer: String {
let count = results.count == rows.count ? "\(rows.count) agents" : "\(results.count) of \(rows.count)"
- return "\(count) ↑↓ select ↩ jump esc close"
+ let unread = selectedRow?.canMarkUnread == true ? " ⌘U mark unread" : ""
+ return "\(count) ↑↓ select ↩ jump\(unread) esc close"
}
/// Printed by --print-search, and compared to skip needless re-renders.
@@ -213,12 +216,14 @@ final class SearchController: NSObject, NSWindowDelegate, NSTextFieldDelegate {
private let field = NSTextField()
private let list = NSStackView()
private let onJump: (String) -> Void
+ private let onMarkUnread: (String) -> Void
private var state = SearchState(rows: [])
private var rendered: String?
private var keyMonitor: Any?
- init(onJump: @escaping (String) -> Void) {
+ init(onJump: @escaping (String) -> Void, onMarkUnread: @escaping (String) -> Void = { _ in }) {
self.onJump = onJump
+ self.onMarkUnread = onMarkUnread
panel = KeyPanel(contentRect: NSRect(x: 0, y: 0, width: searchWidth, height: 80),
styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false)
super.init()
@@ -321,6 +326,14 @@ final class SearchController: NSObject, NSWindowDelegate, NSTextFieldDelegate {
onJump(paneId)
}
+ /// ⌘U: mark the selected agent unread; search stays open, and the agent
+ /// moves to the done ones as the list refreshes.
+ private func markSelectionUnread() {
+ guard let row = state.selectedRow, row.canMarkUnread else { NSSound.beep(); return }
+ log("search: mark \(row.paneId) unread")
+ onMarkUnread(row.paneId)
+ }
+
/// Debug: render `query` over `rows` offscreen to a PNG.
func snapshot(rows: [Row], query: String, down: Int, to path: String) -> Bool {
state = SearchState(rows: rows)
@@ -335,12 +348,17 @@ final class SearchController: NSObject, NSWindowDelegate, NSTextFieldDelegate {
// MARK: keys
/// ⌃N ⌃P ⌃J ⌃K move, ⌃U clears: fzf's keys, before the text field's own
- /// Emacs bindings see them.
+ /// Emacs bindings see them. ⌘U marks the selected agent unread.
private func installKeyMonitor() {
guard keyMonitor == nil else { return }
keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
- guard let self, event.window === self.panel,
- event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .control else { return event }
+ guard let self, event.window === self.panel else { return event }
+ let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
+ if flags == .command, event.charactersIgnoringModifiers == "u" {
+ self.markSelectionUnread()
+ return nil
+ }
+ guard flags == .control else { return event }
switch event.charactersIgnoringModifiers {
case "n", "j": self.move(1)
case "p", "k": self.move(-1)
@@ -392,9 +410,9 @@ final class SearchController: NSObject, NSWindowDelegate, NSTextFieldDelegate {
}
for match in state.visible {
let view = PanelRowView(row: match.row, title: highlightedTitle(match),
- selected: match.row.paneId == state.selected) { [weak self] paneId in
- self?.jump(to: paneId)
- }
+ selected: match.row.paneId == state.selected,
+ onClick: { [weak self] paneId in self?.jump(to: paneId) },
+ onMarkUnread: { [weak self] paneId in self?.onMarkUnread(paneId) })
list.addArrangedSubview(view)
view.widthAnchor.constraint(equalTo: list.widthAnchor).isActive = true
}
@@ -488,5 +506,11 @@ func searchSelftest() -> [String] {
scroll.move(-12)
check(scroll.offset == 0, "state: and back to the top")
check(scroll.footer.hasPrefix("25 agents"), "footer: agent count")
+ let mixed = SearchState(rows: [row("%1", "a", .idle), row("%2", "b", .done)])
+ var onDone = mixed
+ onDone.move(1)
+ check(mixed.selectedRow?.paneId == "%1" && mixed.footer.contains("⌘U mark unread"),
+ "footer: ⌘U offered for an idle agent")
+ check(onDone.selectedRow?.paneId == "%2" && !onDone.footer.contains("⌘U"), "footer: not for a done one")
return failures
}