diff options
Diffstat (limited to 'menubar/search.swift')
| -rw-r--r-- | menubar/search.swift | 492 |
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 +} |
