diff options
| author | Igor Soarez <igor@soarez.org> | 2026-10-27 14:12:28 +0200 |
|---|---|---|
| committer | Igor Soarez <igor@soarez.org> | 2026-10-27 14:12:28 +0200 |
| commit | 50c433e82c368b9861a89deecb74e5f29fa50353 (patch) | |
| tree | fe6d87b79387f6c53ef9bf615b7b530daf9a8d3a /menubar | |
| parent | 9f3a567d4bb1b7b6e42a744936f993bcd437882d (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.swift | 12 | ||||
| -rw-r--r-- | menubar/model.swift | 9 | ||||
| -rw-r--r-- | menubar/panel.swift | 59 | ||||
| -rw-r--r-- | menubar/search.swift | 40 |
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 } |
