From 50c433e82c368b9861a89deecb74e5f29fa50353 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Tue, 27 Oct 2026 14:12:28 +0200 Subject: mark as unread: make an idle agent done again, to come back to it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- menubar/search.swift | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) (limited to 'menubar/search.swift') 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 } -- cgit v1.2.3