aboutsummaryrefslogtreecommitdiff
path: root/menubar/search.swift
diff options
context:
space:
mode:
Diffstat (limited to 'menubar/search.swift')
-rw-r--r--menubar/search.swift492
1 files changed, 492 insertions, 0 deletions
diff --git a/menubar/search.swift b/menubar/search.swift
new file mode 100644
index 0000000..21db063
--- /dev/null
+++ b/menubar/search.swift
@@ -0,0 +1,492 @@
+// Agent search: a Spotlight-style window (global shortcut) that fuzzy-finds
+// any agent and jumps to it.
+//
+// - Opens centered on the screen with the mouse, takes the keyboard without
+// activating dingger (a non-activating key panel), so focus returns to
+// where it was when it closes.
+// - Empty query: every agent in the menu's order, so Enter jumps to the top
+// waiting agent, like the jump shortcut.
+// - Typing filters fzf-style: each word must match, letters in order, one of
+// the agent's name, session, window, harness, status, or directory.
+// - The selection follows the agent, not the position, while states change.
+// - ↑↓ (or ⌃N ⌃P, ⌃J ⌃K) select, ↩ or click jumps, esc closes, ⌃U clears.
+
+import AppKit
+
+// MARK: matching (pure, tested by --selftest)
+
+private func lowered(_ text: String) -> [Character] {
+ text.map { Character(String($0).lowercased().first.map(String.init) ?? String($0)) }
+}
+
+/// fzf-style subsequence match of `term` (already lowercased) in `text`:
+/// nil when the letters do not all appear in order; otherwise a score and
+/// the matched character positions. Word starts and runs of consecutive
+/// letters score higher, gaps lower.
+func fuzzyMatch(_ term: [Character], in text: String) -> (score: Int, positions: [Int])? {
+ guard !term.isEmpty else { return (0, []) }
+ let original = Array(text)
+ let lower = lowered(text)
+ func boundary(_ index: Int) -> Bool {
+ if index == 0 { return true }
+ let previous = original[index - 1], current = original[index]
+ if !(previous.isLetter || previous.isNumber) { return true }
+ return current.isUppercase && previous.isLowercase
+ }
+ // fzf v2-style: the best-scoring alignment, not the first one found.
+ // score[i][j]: best score with term[0...i] matched and term[i] at j.
+ let n = lower.count, m = term.count
+ guard m <= n else { return nil }
+ let none = Int.min
+ var score = Array(repeating: Array(repeating: none, count: n), count: m)
+ var from = Array(repeating: Array(repeating: -1, count: n), count: m)
+ for i in 0..<m {
+ for j in i..<n where lower[j] == term[i] {
+ let base = 16 + (boundary(j) ? 8 : 0)
+ if i == 0 { score[i][j] = base; continue }
+ for k in (i - 1)..<j where score[i - 1][k] != none {
+ let gap = j - k - 1
+ let candidate = score[i - 1][k] + base + (gap == 0 ? 6 : -min(gap, 8))
+ if candidate > score[i][j] { score[i][j] = candidate; from[i][j] = k }
+ }
+ }
+ }
+ guard let end = (0..<n).filter({ score[m - 1][$0] != none }).max(by: { score[m - 1][$0] < score[m - 1][$1] })
+ else { return nil }
+ var positions = [end]
+ var i = m - 1, j = end
+ while i > 0 { j = from[i][j]; i -= 1; positions.insert(j, at: 0) }
+ return (score[m - 1][end], positions)
+}
+
+struct SearchMatch: Equatable {
+ let row: Row
+ let score: Int
+ /// Matched character positions in the row's title (`row.label`).
+ let labelPositions: [Int]
+}
+
+/// What a row can be found by; the title first.
+func searchFields(_ row: Row) -> [String] {
+ [row.name.isEmpty ? row.agent : row.name, row.session, row.window, row.agent, row.status.rawValue, row.path]
+}
+
+/// Rows matching every word of `query`, best first; ties keep the given
+/// order. An empty query returns every row in order.
+func search(_ rows: [Row], _ query: String) -> [SearchMatch] {
+ let terms = query.split(whereSeparator: \.isWhitespace).map { lowered(String($0)) }
+ var matches: [(match: SearchMatch, order: Int)] = []
+ for (order, row) in rows.enumerated() {
+ let fields = searchFields(row)
+ var total = 0
+ var labelPositions = Set<Int>()
+ var matchedAll = true
+ for term in terms {
+ var best: (score: Int, field: Int, positions: [Int])?
+ for (field, text) in fields.enumerated() {
+ if let found = fuzzyMatch(term, in: text), best == nil || found.score > best!.score {
+ best = (found.score, field, found.positions)
+ }
+ }
+ guard let best else { matchedAll = false; break }
+ total += best.score
+ if best.field == 0 { labelPositions.formUnion(best.positions) }
+ }
+ guard matchedAll else { continue }
+ matches.append((SearchMatch(row: row, score: total, labelPositions: labelPositions.sorted()), order))
+ }
+ matches.sort { $0.match.score != $1.match.score ? $0.match.score > $1.match.score : $0.order < $1.order }
+ return matches.map(\.match)
+}
+
+/// The search window's state: query, results, and the selected agent.
+struct SearchState: Equatable {
+ static let visibleRows = 10
+
+ private(set) var rows: [Row]
+ private(set) var query = ""
+ private(set) var results: [SearchMatch] = []
+ /// The selected agent's pane, so the selection survives reordering.
+ private(set) var selected: String?
+ /// First result shown; the list scrolls to keep the selection visible.
+ private(set) var offset = 0
+
+ init(rows: [Row]) {
+ self.rows = rows
+ recompute(keepSelection: false)
+ }
+
+ var selectedIndex: Int? { results.firstIndex { $0.row.paneId == selected } }
+ var visible: ArraySlice<SearchMatch> { results[offset..<min(results.count, offset + Self.visibleRows)] }
+
+ /// A new query selects the best match.
+ mutating func setQuery(_ query: String) {
+ self.query = query
+ recompute(keepSelection: false)
+ }
+
+ /// Fresh rows keep the selected agent selected while it still matches.
+ mutating func update(rows: [Row]) {
+ self.rows = rows
+ recompute(keepSelection: true)
+ }
+
+ mutating func move(_ delta: Int) {
+ guard !results.isEmpty else { return }
+ let index = max(0, min(results.count - 1, (selectedIndex ?? 0) + delta))
+ selected = results[index].row.paneId
+ scrollToSelection()
+ }
+
+ private mutating func recompute(keepSelection: Bool) {
+ let previous = selectedIndex
+ results = search(rows, query)
+ if !(keepSelection && selectedIndex != nil) {
+ // Keep the selection's position when its agent disappears.
+ let index = keepSelection ? min(previous ?? 0, results.count - 1) : 0
+ selected = results.isEmpty ? nil : results[max(0, index)].row.paneId
+ if !keepSelection { offset = 0 }
+ }
+ scrollToSelection()
+ }
+
+ private mutating func scrollToSelection() {
+ let index = selectedIndex ?? 0
+ if index < offset { offset = index }
+ if index >= offset + Self.visibleRows { offset = index - Self.visibleRows + 1 }
+ offset = max(0, min(offset, max(0, results.count - Self.visibleRows)))
+ }
+
+ var footer: String {
+ let count = results.count == rows.count ? "\(rows.count) agents" : "\(results.count) of \(rows.count)"
+ return "\(count) ↑↓ select ↩ jump esc close"
+ }
+
+ /// Printed by --print-search, and compared to skip needless re-renders.
+ var plainText: String {
+ var lines: [String] = []
+ if results.isEmpty {
+ lines.append(rows.isEmpty ? "No agents" : "No agents match '\(query)'")
+ }
+ for match in visible {
+ let row = match.row
+ let marker = row.paneId == selected ? "›" : " "
+ lines.append("\(marker) \(row.status.symbol) \(row.status.rawValue)\t\(row.label)\t\(row.subtitle)")
+ }
+ lines.append(footer)
+ return lines.joined(separator: "\n")
+ }
+}
+
+// MARK: window
+
+private let searchWidth: CGFloat = 580
+
+/// A borderless panel that can take the keyboard without activating dingger.
+private final class KeyPanel: NSPanel {
+ override var canBecomeKey: Bool { true }
+}
+
+/// Title with the matched letters highlighted.
+private func highlightedTitle(_ match: SearchMatch) -> NSAttributedString {
+ let text = NSMutableAttributedString(
+ attributedString: rowTitle(match.row, primary: mochaText, secondary: mochaSubtext))
+ guard !match.labelPositions.isEmpty else { return text }
+ let prefix = "\(match.row.status.symbol) ".utf16.count
+ let font = NSFont.boldSystemFont(ofSize: NSFont.menuFont(ofSize: 0).pointSize)
+ let label = Array(match.row.label)
+ var utf16Offset = prefix
+ let wanted = Set(match.labelPositions)
+ for (index, character) in label.enumerated() {
+ let length = String(character).utf16.count
+ if wanted.contains(index) {
+ let range = NSRange(location: utf16Offset, length: length)
+ text.addAttributes([.foregroundColor: mochaBlue, .font: font], range: range)
+ }
+ utf16Offset += length
+ }
+ return text
+}
+
+final class SearchController: NSObject, NSWindowDelegate, NSTextFieldDelegate {
+ private let panel: KeyPanel
+ private let field = NSTextField()
+ private let list = NSStackView()
+ private let onJump: (String) -> Void
+ private var state = SearchState(rows: [])
+ private var rendered: String?
+ private var keyMonitor: Any?
+
+ init(onJump: @escaping (String) -> Void) {
+ self.onJump = onJump
+ panel = KeyPanel(contentRect: NSRect(x: 0, y: 0, width: searchWidth, height: 80),
+ styleMask: [.nonactivatingPanel, .borderless], backing: .buffered, defer: false)
+ super.init()
+ panel.isFloatingPanel = true
+ panel.level = NSWindow.Level(rawValue: NSWindow.Level.floating.rawValue + 1)
+ panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
+ panel.hidesOnDeactivate = false
+ panel.becomesKeyOnlyIfNeeded = false
+ panel.backgroundColor = .clear
+ panel.isOpaque = false
+ panel.hasShadow = true
+ panel.appearance = NSAppearance(named: .darkAqua)
+ panel.delegate = self
+
+ let background = darkHUD()
+ panel.contentView = background
+
+ field.isBezeled = false
+ field.drawsBackground = false
+ field.focusRingType = .none
+ field.font = .systemFont(ofSize: 20)
+ field.textColor = mochaText
+ field.placeholderAttributedString = NSAttributedString(
+ string: "Search agents", attributes: [.font: NSFont.systemFont(ofSize: 20), .foregroundColor: mochaOverlay])
+ field.cell?.usesSingleLineMode = true
+ field.cell?.isScrollable = true
+ field.delegate = self
+
+ let line = NSView()
+ line.wantsLayer = true
+ line.layer?.backgroundColor = mochaOverlay.withAlphaComponent(0.4).cgColor
+
+ list.orientation = .vertical
+ list.alignment = .leading
+ list.spacing = 0
+
+ for view in [field, line, list] {
+ view.translatesAutoresizingMaskIntoConstraints = false
+ background.addSubview(view)
+ }
+ NSLayoutConstraint.activate([
+ field.leadingAnchor.constraint(equalTo: background.leadingAnchor, constant: 18),
+ field.trailingAnchor.constraint(equalTo: background.trailingAnchor, constant: -18),
+ field.topAnchor.constraint(equalTo: background.topAnchor, constant: 14),
+ line.heightAnchor.constraint(equalToConstant: 1),
+ line.leadingAnchor.constraint(equalTo: background.leadingAnchor),
+ line.trailingAnchor.constraint(equalTo: background.trailingAnchor),
+ line.topAnchor.constraint(equalTo: field.bottomAnchor, constant: 12),
+ list.leadingAnchor.constraint(equalTo: background.leadingAnchor, constant: 8),
+ list.trailingAnchor.constraint(equalTo: background.trailingAnchor, constant: -8),
+ list.topAnchor.constraint(equalTo: line.bottomAnchor, constant: 6),
+ ])
+ }
+
+ var isOpen: Bool { panel.isVisible }
+
+ func open(rows: [Row]) {
+ state = SearchState(rows: rows)
+ field.stringValue = ""
+ rendered = nil
+ render()
+ // Spotlight's spot: centered, a fifth of the way down the screen the
+ // mouse is on.
+ let mouse = NSEvent.mouseLocation
+ let screen = NSScreen.screens.first { $0.frame.contains(mouse) } ?? NSScreen.main
+ if let area = screen?.visibleFrame {
+ panel.setFrameTopLeftPoint(NSPoint(x: area.midX - searchWidth / 2, y: area.maxY - area.height / 5))
+ render(force: true)
+ }
+ panel.makeKeyAndOrderFront(nil)
+ panel.makeFirstResponder(field)
+ installKeyMonitor()
+ log("search: opened (key: \(panel.isKeyWindow), \(rows.count) agents)")
+ }
+
+ func close(_ reason: String) {
+ guard isOpen else { return }
+ if let keyMonitor { NSEvent.removeMonitor(keyMonitor) }
+ keyMonitor = nil
+ panel.orderOut(nil)
+ log("search: closed by \(reason) (query '\(state.query)', \(state.results.count) results, "
+ + "selected \(state.selected ?? "none"))")
+ }
+
+ func update(rows: [Row]) {
+ guard isOpen else { return }
+ state.update(rows: rows)
+ render()
+ }
+
+ private func jumpToSelection() {
+ guard let paneId = state.selected else { NSSound.beep(); return }
+ jump(to: paneId)
+ }
+
+ private func jump(to paneId: String) {
+ let query = state.query
+ close("jump")
+ log("search: jump to \(paneId) (query '\(query)')")
+ onJump(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)
+ field.stringValue = query
+ state.setQuery(query)
+ state.move(down)
+ render(force: true)
+ guard let view = panel.contentView else { return false }
+ return snapshotView(view, to: path)
+ }
+
+ // MARK: keys
+
+ /// ⌃N ⌃P ⌃J ⌃K move, ⌃U clears: fzf's keys, before the text field's own
+ /// Emacs bindings see them.
+ 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 }
+ switch event.charactersIgnoringModifiers {
+ case "n", "j": self.move(1)
+ case "p", "k": self.move(-1)
+ case "u":
+ self.field.stringValue = ""
+ self.controlTextDidChange(Notification(name: NSControl.textDidChangeNotification))
+ default: return event
+ }
+ return nil
+ }
+ }
+
+ func control(_ control: NSControl, textView: NSTextView, doCommandBy selector: Selector) -> Bool {
+ switch selector {
+ case #selector(NSResponder.moveDown(_:)): move(1)
+ case #selector(NSResponder.moveUp(_:)): move(-1)
+ case #selector(NSResponder.insertNewline(_:)): jumpToSelection()
+ case #selector(NSResponder.cancelOperation(_:)): close("esc")
+ default: return false
+ }
+ return true
+ }
+
+ func controlTextDidChange(_ notification: Notification) {
+ state.setQuery(field.stringValue)
+ render()
+ }
+
+ private func move(_ delta: Int) {
+ state.move(delta)
+ render()
+ }
+
+ func windowDidResignKey(_ notification: Notification) { close("clicking elsewhere") }
+
+ // MARK: rendering
+
+ private func render(force: Bool = false) {
+ let text = state.plainText
+ guard force || text != rendered else { return }
+ rendered = text
+ list.arrangedSubviews.forEach { view in
+ list.removeArrangedSubview(view)
+ view.removeFromSuperview()
+ }
+ if state.results.isEmpty {
+ list.addArrangedSubview(searchCaption(
+ state.rows.isEmpty ? "No agents" : "No agents match '\(state.query)'", color: mochaSubtext))
+ }
+ 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)
+ }
+ list.addArrangedSubview(view)
+ view.widthAnchor.constraint(equalTo: list.widthAnchor).isActive = true
+ }
+ list.addArrangedSubview(searchCaption(state.footer, color: mochaOverlay))
+
+ // Grow or shrink downward from the top edge.
+ panel.contentView?.layoutSubtreeIfNeeded()
+ let height = 14 + field.fittingSize.height + 12 + 1 + 6 + list.fittingSize.height + 8
+ let top = panel.frame.maxY
+ panel.setFrame(NSRect(x: panel.frame.minX, y: top - height, width: searchWidth, height: height),
+ display: true)
+ }
+
+ private func searchCaption(_ text: String, color: NSColor) -> NSView {
+ let label = NSTextField(labelWithString: text)
+ label.font = .systemFont(ofSize: 11)
+ label.textColor = color
+ 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: -2),
+ ])
+ return box
+ }
+}
+
+// MARK: selftest
+
+func searchSelftest() -> [String] {
+ var failures: [String] = []
+ func check(_ condition: Bool, _ message: String) {
+ if !condition { failures.append(message) }
+ }
+ func row(_ id: String, _ name: String, _ status: Status = .idle, session: String = "s",
+ window: String = "w", path: String = "") -> Row {
+ Row(paneId: id, agent: "pi", name: name, status: status, session: session, window: window,
+ since: 1, path: path)
+ }
+ func ids(_ matches: [SearchMatch]) -> [String] { matches.map(\.row.paneId) }
+
+ check(fuzzyMatch(lowered("lgsc"), in: "LangGraph feature scanner") != nil, "match: letters in order")
+ check(fuzzyMatch(lowered("longer"), in: "lon") == nil && fuzzyMatch(lowered("a"), in: "") == nil,
+ "match: a term longer than the text")
+ check(fuzzyMatch(lowered("csgl"), in: "LangGraph feature scanner") == nil, "match: order matters")
+ check(fuzzyMatch(lowered("LANG"), in: "langgraph")?.positions == [0, 1, 2, 3], "match: case-insensitive")
+ check(fuzzyMatch(lowered("lg"), in: "LangGraph")?.positions == [0, 4], "match: prefers word starts (camel case)")
+ check(fuzzyMatch(lowered("scan"), in: "a secret scanner")?.positions == [9, 10, 11, 12],
+ "match: prefers a consecutive run at a word start over the first letters")
+
+ let rows = [
+ row("%1", "Review LangGraph feature scanner", .done, session: "scout"),
+ row("%2", "Temporal crash-resume demo", .working, session: "temporal"),
+ row("%3", "Deploy the widget", session: "tools", path: "~/src/langgraph"),
+ row("%4", "", session: "sidequests", window: "notes"),
+ ]
+ check(ids(search(rows, "")) == ["%1", "%2", "%3", "%4"], "empty query: every agent, in order")
+ check(ids(search(rows, "lgsc")) == ["%1"], "query: fuzzy name")
+ check(ids(search(rows, "langgraph")) == ["%1", "%3"], "query: directories match too")
+ check(ids(search(rows, "temporal demo")) == ["%2"], "query: every word must match")
+ check(ids(search(rows, "done")) == ["%1"], "query: status word")
+ check(ids(search(rows, "notes")) == ["%4"], "query: window")
+ check(ids(search(rows, "zzz")).isEmpty, "query: no match")
+ check(search(rows, "lgsc").first?.labelPositions == [7, 11, 25, 26], "highlight: matched title letters")
+ check(search(rows, "langgraph").last?.labelPositions == [], "highlight: none for a directory match")
+
+ var state = SearchState(rows: rows)
+ check(state.selected == "%1", "state: the first agent is selected")
+ state.move(1)
+ check(state.selected == "%2", "state: down")
+ state.move(-5)
+ check(state.selected == "%1", "state: stops at the top")
+ state.move(2)
+ state.update(rows: [rows[2], rows[0], rows[1], rows[3]])
+ check(state.selected == "%3", "state: selection follows the agent when rows reorder")
+ state.update(rows: [rows[0], rows[1], rows[3]])
+ check(state.selected == "%1", "state: when the selected agent goes, the row now in its place")
+ state.setQuery("temporal")
+ check(state.selected == "%2" && state.results.count == 1, "state: a new query selects the best match")
+ state.setQuery("zzz")
+ check(state.selected == nil && state.plainText.hasPrefix("No agents match 'zzz'"), "state: no results")
+
+ let many = (0..<25).map { row("%\($0)", "agent \($0)") }
+ var scroll = SearchState(rows: many)
+ scroll.move(12)
+ check(scroll.visible.count == SearchState.visibleRows && scroll.visible.last?.row.paneId == "%12",
+ "state: the list scrolls to keep the selection visible")
+ scroll.move(-12)
+ check(scroll.offset == 0, "state: and back to the top")
+ check(scroll.footer.hasPrefix("25 agents"), "footer: agent count")
+ return failures
+}