aboutsummaryrefslogtreecommitdiff
path: root/menubar/search.swift
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/search.swift
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/search.swift')
-rw-r--r--menubar/search.swift40
1 files changed, 32 insertions, 8 deletions
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
}