aboutsummaryrefslogtreecommitdiff
path: root/menubar
diff options
context:
space:
mode:
authorIgor Soarez <igor@soarez.org>2026-10-22 18:01:04 +0300
committerIgor Soarez <igor@soarez.org>2026-10-22 18:01:04 +0300
commitf4a7346fd32f906ccc814364462c18afb3f10616 (patch)
treec4502bc0a6725233e9ba45c5350bf7be44100f1d /menubar
parent7649396bf30a65096fc3944e1404835e661f00af (diff)
agent search: spotlight-style fuzzy finder on ctrl+opt+cmd+shift+u
a centered, non-activating key panel that lists every agent and jumps to the chosen one through the same dingger focus path as a row click. - fzf-style matching: each word must match one of name, session, window, harness, status, or directory, letters in order; the best alignment wins (word starts and consecutive runs score higher), matched title letters are highlighted. - empty query keeps the menu's order, so enter jumps to the top waiting agent. - the selection follows the agent, not the position, as states change; the list scrolls to keep it visible. - keys: arrows, ctrl+n/p, ctrl+j/k, enter, esc, ctrl+u; the shortcut again or clicking elsewhere closes it. - search_hotkey setting; an unedited config file is upgraded. menu item "Search Agents...". rows carry the pane's directory. - --print-search and --snapshot-search debug flags; the panel's background and snapshot code are shared. selftest covers matching, ranking, highlighting, and selection; e2e step 15 checks queries through the real binary (fails when search only looks at titles). e2e resolves relative binary paths, which tmux hooks need.
Diffstat (limited to 'menubar')
-rw-r--r--menubar/hotkey.swift25
-rw-r--r--menubar/main.swift57
-rw-r--r--menubar/model.swift12
-rw-r--r--menubar/panel.swift108
-rw-r--r--menubar/search.swift492
5 files changed, 637 insertions, 57 deletions
diff --git a/menubar/hotkey.swift b/menubar/hotkey.swift
index c5ab178..136d94d 100644
--- a/menubar/hotkey.swift
+++ b/menubar/hotkey.swift
@@ -12,6 +12,7 @@ import Carbon.HIToolbox
struct Settings: Equatable {
var hotkey = "ctrl+opt+cmd+e"
var jumpHotkey = "ctrl+opt+cmd+u"
+ var searchHotkey = "ctrl+opt+cmd+shift+u"
static var directory: String {
environment["DINGGER_CONFIG_DIR"] ?? (NSHomeDirectory() + "/.config/dingger")
@@ -28,10 +29,28 @@ struct Settings: Equatable {
# Jump to the agent at the top of the waiting list (blocked first, then
# the longest done), like clicking the tmux status line.
jump_hotkey = "ctrl+opt+cmd+u"
+ # Search all agents by name, session, window, harness, status, or
+ # directory, and jump to one.
+ search_hotkey = "ctrl+opt+cmd+shift+u"
+
+ """
+
+ /// Earlier templates, replaced on upgrade if the user never edited them.
+ private static let olderTemplates = [firstTemplate, secondTemplate]
+
+ private static let secondTemplate = """
+ # dingger settings. The menu bar app reloads this file when you save it.
+ # Shortcuts: modifiers ctrl, opt, cmd, shift; keys a-z, 0-9, f1-f12, space.
+
+ [shortcuts]
+ # Show or hide the floating panel.
+ hotkey = "ctrl+opt+cmd+e"
+ # Jump to the agent at the top of the waiting list (blocked first, then
+ # the longest done), like clicking the tmux status line.
+ jump_hotkey = "ctrl+opt+cmd+u"
"""
- /// The first template, replaced on upgrade if the user never edited it.
private static let firstTemplate = """
# dingger settings. The menu bar app reloads this file when you save it.
@@ -46,7 +65,7 @@ struct Settings: Equatable {
/// upgrade it if it is still an unedited older template.
static func ensureFile() {
let existing = try? String(contentsOfFile: path, encoding: .utf8)
- guard existing == nil || existing == firstTemplate else { return }
+ guard existing == nil || olderTemplates.contains(existing!) else { return }
try? FileManager.default.createDirectory(atPath: directory, withIntermediateDirectories: true)
try? template.write(toFile: path, atomically: true, encoding: .utf8)
}
@@ -64,6 +83,7 @@ struct Settings: Equatable {
switch parts[0] {
case "hotkey": settings.hotkey = value
case "jump_hotkey": settings.jumpHotkey = value
+ case "search_hotkey": settings.searchHotkey = value
default: break
}
}
@@ -139,6 +159,7 @@ enum GlobalHotkey {
enum Id: UInt32 {
case panel = 1
case jump = 2
+ case search = 3
}
private static var references: [UInt32: EventHotKeyRef] = [:]
diff --git a/menubar/main.swift b/menubar/main.swift
index 183c5db..a00288c 100644
--- a/menubar/main.swift
+++ b/menubar/main.swift
@@ -1,6 +1,7 @@
// dingger menu bar: herdr's agent panel as a macOS status item, plus a
-// floating always-on-top panel (panel.swift) toggled by a global shortcut
-// (hotkey.swift). Rows and colors come from model.swift.
+// floating always-on-top panel (panel.swift) and agent search (search.swift),
+// each on a global shortcut (hotkey.swift). Rows and colors come from
+// model.swift.
//
// Reads the pane options the dingger daemon writes and renders herdr's
// Symbols style with herdr's palette:
@@ -19,8 +20,12 @@
// --snapshot-panel <png> [--full]
// render the panel offscreen (after a different render, to
// catch stale content) over white and black, then exit
+// --print-search <query>
+// print what agent search would list for <query>, then exit
+// --snapshot-search <png> <query> [<down>]
+// render agent search offscreen, then exit
// --click <pane> perform a row click for <pane>, then exit
-// --selftest check the panel visibility rules, then exit
+// --selftest check the panel and search rules, then exit
import AppKit
@@ -32,6 +37,7 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
private let item = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
private let menu = NSMenu()
private var panel: PanelController!
+ private var searchWindow: SearchController!
private var visibility = Menubar.loadVisibility()
private var settings = Settings()
private var settingsModified: Date?
@@ -39,6 +45,8 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
private var hotkeyRegistered = false
private var jumpHotkey: Hotkey?
private var jumpHotkeyRegistered = false
+ private var searchHotkey: Hotkey?
+ private var searchHotkeyRegistered = false
private var prefersFull = UserDefaults.standard.bool(forKey: "panelFull")
private var rows: [Row] = []
@@ -64,6 +72,7 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
panel = PanelController(
onClick: { [weak self] paneId in self?.jump(to: paneId) },
onToggleFull: { [weak self] in self?.toggleFull() })
+ searchWindow = SearchController(onJump: { [weak self] paneId in self?.jump(to: paneId) })
Settings.ensureFile()
reloadSettings()
refresh()
@@ -82,6 +91,9 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
(jumpHotkey, jumpHotkeyRegistered) = registerShortcut(.jump, settings.jumpHotkey, "jump") {
[weak self] in self?.jumpToTopWaiting()
}
+ (searchHotkey, searchHotkeyRegistered) = registerShortcut(.search, settings.searchHotkey, "search") {
+ [weak self] in self?.toggleSearch()
+ }
}
private func registerShortcut(_ id: GlobalHotkey.Id, _ spec: String, _ name: String,
@@ -113,6 +125,17 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
visibility.anyWaiting = rows.contains(where: \.waiting)
applyPanel()
+ searchWindow.update(rows: rows)
+ }
+
+ /// The search shortcut and menu item: open agent search, or close it.
+ @objc private func toggleSearch() {
+ if searchWindow.isOpen {
+ searchWindow.close("shortcut")
+ } else {
+ rows = fetchRows()
+ searchWindow.open(rows: rows)
+ }
}
private var panelContent: PanelContent {
@@ -229,6 +252,15 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
}
menu.addItem(jumpItem)
+ let searchItem = NSMenuItem(title: "Search Agents…", action: #selector(toggleSearch), keyEquivalent: "")
+ searchItem.target = self
+ searchItem.isEnabled = !rows.isEmpty
+ if let searchHotkey, searchHotkeyRegistered {
+ searchItem.keyEquivalent = searchHotkey.key.count == 1 ? searchHotkey.key : ""
+ searchItem.keyEquivalentModifierMask = searchHotkey.modifierFlags
+ }
+ menu.addItem(searchItem)
+
let policyMenu = NSMenu()
for policy in PanelPolicy.allCases {
let option = NSMenuItem(title: policy.label, action: #selector(selectPolicy(_:)), keyEquivalent: "")
@@ -242,7 +274,8 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
menu.addItem(policyItem)
for (shortcut, registered, spec) in [(hotkey, hotkeyRegistered, settings.hotkey),
- (jumpHotkey, jumpHotkeyRegistered, settings.jumpHotkey)] {
+ (jumpHotkey, jumpHotkeyRegistered, settings.jumpHotkey),
+ (searchHotkey, searchHotkeyRegistered, settings.searchHotkey)] {
guard !registered else { continue }
let problem = shortcut.map { "Shortcut \($0.display) is used by another app" }
?? "Shortcut \"\(spec)\" is not valid"
@@ -267,12 +300,13 @@ final class Menubar: NSObject, NSApplicationDelegate, NSMenuDelegate {
let arguments = CommandLine.arguments
if arguments.contains("--selftest") {
- var failures = panelSelftest()
+ var failures = panelSelftest() + searchSelftest()
if Hotkey.parse("ctrl+opt+cmd+e")?.display != "⌃⌥⌘E" { failures.append("hotkey: parse ctrl+opt+cmd+e") }
if Hotkey.parse("e") != nil { failures.append("hotkey: a key without modifiers is rejected") }
if Hotkey.parse("ctrl+opt+bogus") != nil { failures.append("hotkey: unknown keys are rejected") }
if Hotkey.parse("cmd+shift+f12")?.display != "⇧⌘F12" { failures.append("hotkey: parse cmd+shift+f12") }
if Hotkey.parse(Settings().jumpHotkey)?.display != "⌃⌥⌘U" { failures.append("hotkey: default jump is ⌃⌥⌘U") }
+ if Hotkey.parse(Settings().searchHotkey)?.display != "⌃⌥⇧⌘U" { failures.append("hotkey: default search is ⌃⌥⇧⌘U") }
failures.forEach { print("FAIL \($0)") }
print(failures.isEmpty ? "selftest ok" : "selftest: \(failures.count) failed")
exit(failures.isEmpty ? 0 : 1)
@@ -301,6 +335,19 @@ if let index = arguments.firstIndex(of: "--snapshot-panel"), index + 1 < argumen
controller.render(PanelContent(rows: fetchRows(), full: full, canToggle: full))
exit(controller.snapshot(to: arguments[index + 1]) ? 0 : 1)
}
+if let index = arguments.firstIndex(of: "--print-search") {
+ var state = SearchState(rows: fetchRows())
+ state.setQuery(index + 1 < arguments.count ? arguments[index + 1] : "")
+ print(state.plainText)
+ exit(0)
+}
+if let index = arguments.firstIndex(of: "--snapshot-search"), index + 2 < arguments.count {
+ _ = NSApplication.shared
+ let controller = SearchController(onJump: { _ in })
+ let down = index + 3 < arguments.count ? Int(arguments[index + 3]) ?? 0 : 0
+ exit(controller.snapshot(rows: fetchRows(), query: arguments[index + 2], down: down,
+ to: arguments[index + 1]) ? 0 : 1)
+}
if let index = arguments.firstIndex(of: "--click"), index + 1 < arguments.count {
focus(arguments[index + 1])
exit(0)
diff --git a/menubar/model.swift b/menubar/model.swift
index 67a693a..e891697 100644
--- a/menubar/model.swift
+++ b/menubar/model.swift
@@ -27,6 +27,7 @@ func mocha(_ r: CGFloat, _ g: CGFloat, _ b: CGFloat, _ a: CGFloat = 1) -> NSColo
let mochaText = mocha(205, 214, 244)
let mochaSubtext = mocha(166, 173, 200)
let mochaOverlay = mocha(108, 112, 134)
+let mochaBlue = mocha(137, 180, 250)
// MARK: herdr's status vocabulary
@@ -73,6 +74,8 @@ struct Row: Equatable {
let session: String
let window: String
let since: Int
+ /// The pane's working directory, home as "~".
+ var path: String = ""
var location: String { "\(session) · \(window)" }
var waiting: Bool { status == .blocked || status == .done }
@@ -162,21 +165,24 @@ func tmux(_ args: [String]) -> String? {
let dinggerBinary = environment["DINGGER_BIN"] ?? (NSHomeDirectory() + "/.local/bin/dingger")
+private let home = NSHomeDirectory()
+
/// Every agent pane, in herdr's priority order, oldest waiting first.
func fetchRows() -> [Row] {
let format = [
"#{pane_id}", "#{@agent}", "#{@agent_state}", "#{@dingger_since}", "#{@agent_name}",
- "#{session_name}", "#{window_name}", "#{window_panes}", "#{pane_index}",
+ "#{session_name}", "#{window_name}", "#{window_panes}", "#{pane_index}", "#{pane_current_path}",
].joined(separator: "\t")
guard let out = tmux(["list-panes", "-a", "-F", format]) else { return [] }
let rows: [Row] = out.split(separator: "\n").compactMap { line in
let f = line.split(separator: "\t", omittingEmptySubsequences: false).map(String.init)
- guard f.count >= 9, !f[1].isEmpty else { return nil }
+ guard f.count >= 10, !f[1].isEmpty else { return nil }
let status = Status(rawValue: f[2]) ?? .unknown
guard status != .unknown else { return nil }
let window = (Int(f[7]) ?? 1) > 1 ? "\(f[6]).\(f[8])" : f[6]
return Row(paneId: f[0], agent: f[1], name: f[4], status: status,
- session: f[5], window: window, since: Int(f[3]) ?? Int.max)
+ session: f[5], window: window, since: Int(f[3]) ?? Int.max,
+ path: f[9].hasPrefix(home) ? "~" + f[9].dropFirst(home.count) : f[9])
}
return rows.sorted {
if $0.status.priority != $1.status.priority { return $0.status.priority > $1.status.priority }
diff --git a/menubar/panel.swift b/menubar/panel.swift
index 2b3cad2..003a061 100644
--- a/menubar/panel.swift
+++ b/menubar/panel.swift
@@ -147,8 +147,31 @@ private let inset: CGFloat = 8
/// desktops.
private let panelTint = mocha(30, 30, 46, 0.72)
+/// The panel's background: HUD blur under herdr's mocha base, rounded.
+func darkHUD() -> NSVisualEffectView {
+ let effect = NSVisualEffectView()
+ effect.material = .hudWindow
+ effect.blendingMode = .behindWindow
+ effect.state = .active
+ effect.wantsLayer = true
+ effect.layer?.cornerRadius = 12
+ effect.layer?.masksToBounds = true
+ let tint = NSView()
+ tint.wantsLayer = true
+ tint.layer?.backgroundColor = panelTint.cgColor
+ tint.translatesAutoresizingMaskIntoConstraints = false
+ effect.addSubview(tint)
+ NSLayoutConstraint.activate([
+ tint.leadingAnchor.constraint(equalTo: effect.leadingAnchor),
+ tint.trailingAnchor.constraint(equalTo: effect.trailingAnchor),
+ tint.topAnchor.constraint(equalTo: effect.topAnchor),
+ tint.bottomAnchor.constraint(equalTo: effect.bottomAnchor),
+ ])
+ return effect
+}
+
/// A click target that does not swallow drags: a drag moves the panel.
-private class ClickView: NSView {
+class ClickView: NSView {
var onClick: () -> Void = {}
private var dragged = false
var hovered = false { didSet { needsDisplay = true; hoverChanged() } }
@@ -185,7 +208,6 @@ private class ClickView: NSView {
}
override func mouseUp(with event: NSEvent) {
- log("panel: mouse up (dragged: \(dragged))")
if !dragged { onClick() }
}
}
@@ -210,12 +232,16 @@ private func singleLine(_ label: NSTextField) -> NSTextField {
}
/// One agent row: colored symbol and name, then a dim subtitle.
-private final class PanelRowView: ClickView {
- init(row: Row, onClick: @escaping (String) -> Void) {
+final class PanelRowView: ClickView {
+ private let selected: Bool
+
+ init(row: Row, title titleText: NSAttributedString? = nil, selected: Bool = false,
+ onClick: @escaping (String) -> Void) {
+ self.selected = selected
super.init(frame: .zero)
self.onClick = { onClick(row.paneId) }
let title = singleLine(NSTextField(
- labelWithAttributedString: rowTitle(row, primary: mochaText, secondary: mochaSubtext)))
+ labelWithAttributedString: titleText ?? rowTitle(row, primary: mochaText, secondary: mochaSubtext)))
let subtitleLabel = NSTextField(labelWithString: row.subtitle)
subtitleLabel.font = .systemFont(ofSize: 11)
subtitleLabel.textColor = mochaSubtext
@@ -240,8 +266,8 @@ private final class PanelRowView: ClickView {
required init?(coder: NSCoder) { fatalError("not used") }
override func draw(_ dirtyRect: NSRect) {
- guard hovered else { return }
- NSColor.white.withAlphaComponent(0.08).setFill()
+ guard hovered || selected else { return }
+ NSColor.white.withAlphaComponent(selected ? 0.14 : 0.08).setFill()
NSBezierPath(roundedRect: bounds, xRadius: 6, yRadius: 6).fill()
}
}
@@ -294,31 +320,15 @@ final class PanelController {
panel.hasShadow = true
panel.appearance = NSAppearance(named: .darkAqua)
- let effect = NSVisualEffectView()
- effect.material = .hudWindow
- effect.blendingMode = .behindWindow
- effect.state = .active
- effect.wantsLayer = true
- effect.layer?.cornerRadius = 12
- effect.layer?.masksToBounds = true
+ let effect = darkHUD()
panel.contentView = effect
- let tint = NSView()
- tint.wantsLayer = true
- tint.layer?.backgroundColor = panelTint.cgColor
- tint.translatesAutoresizingMaskIntoConstraints = false
- effect.addSubview(tint)
-
stack.orientation = .vertical
stack.alignment = .leading
stack.spacing = 0
stack.translatesAutoresizingMaskIntoConstraints = false
effect.addSubview(stack)
NSLayoutConstraint.activate([
- tint.leadingAnchor.constraint(equalTo: effect.leadingAnchor),
- tint.trailingAnchor.constraint(equalTo: effect.trailingAnchor),
- tint.topAnchor.constraint(equalTo: effect.topAnchor),
- tint.bottomAnchor.constraint(equalTo: effect.bottomAnchor),
stack.leadingAnchor.constraint(equalTo: effect.leadingAnchor, constant: inset),
stack.trailingAnchor.constraint(equalTo: effect.trailingAnchor, constant: -inset),
stack.topAnchor.constraint(equalTo: effect.topAnchor, constant: inset),
@@ -420,32 +430,36 @@ final class PanelController {
func show() { panel.orderFrontRegardless() }
func hide() { panel.orderOut(nil) }
- /// Debug: draw the panel offscreen over a white and a black backdrop,
- /// side by side, as a PNG.
func snapshot(to path: String) -> Bool {
guard let view = panel.contentView else { return false }
- view.layoutSubtreeIfNeeded()
- guard let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false }
- view.cacheDisplay(in: view.bounds, to: rep)
- let size = view.bounds.size
- let image = NSImage(size: NSSize(width: size.width * 2 + 20, height: size.height))
- image.lockFocus()
- NSColor.white.setFill()
- NSRect(x: 0, y: 0, width: size.width + 10, height: size.height).fill()
- NSColor.black.setFill()
- NSRect(x: size.width + 10, y: 0, width: size.width + 10, height: size.height).fill()
- // the blur does not render offscreen: approximate it with a mid gray
- for x in [CGFloat(0), size.width + 20] {
- NSColor(white: 0.5, alpha: 0.5).setFill()
- NSBezierPath(roundedRect: NSRect(x: x, y: 0, width: size.width, height: size.height),
- xRadius: 12, yRadius: 12).fill()
- rep.draw(in: NSRect(x: x, y: 0, width: size.width, height: size.height))
- }
- image.unlockFocus()
- guard let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff),
- let png = bitmap.representation(using: .png, properties: [:]) else { return false }
- return (try? png.write(to: URL(fileURLWithPath: path))) != nil
+ return snapshotView(view, to: path)
+ }
+}
+
+/// Debug: draw a dark HUD view offscreen over a white and a black backdrop,
+/// side by side, as a PNG.
+func snapshotView(_ view: NSView, to path: String) -> Bool {
+ view.layoutSubtreeIfNeeded()
+ guard let rep = view.bitmapImageRepForCachingDisplay(in: view.bounds) else { return false }
+ view.cacheDisplay(in: view.bounds, to: rep)
+ let size = view.bounds.size
+ let image = NSImage(size: NSSize(width: size.width * 2 + 20, height: size.height))
+ image.lockFocus()
+ NSColor.white.setFill()
+ NSRect(x: 0, y: 0, width: size.width + 10, height: size.height).fill()
+ NSColor.black.setFill()
+ NSRect(x: size.width + 10, y: 0, width: size.width + 10, height: size.height).fill()
+ // the blur does not render offscreen: approximate it with a mid gray
+ for x in [CGFloat(0), size.width + 20] {
+ NSColor(white: 0.5, alpha: 0.5).setFill()
+ NSBezierPath(roundedRect: NSRect(x: x, y: 0, width: size.width, height: size.height),
+ xRadius: 12, yRadius: 12).fill()
+ rep.draw(in: NSRect(x: x, y: 0, width: size.width, height: size.height))
}
+ image.unlockFocus()
+ guard let tiff = image.tiffRepresentation, let bitmap = NSBitmapImageRep(data: tiff),
+ let png = bitmap.representation(using: .png, properties: [:]) else { return false }
+ return (try? png.write(to: URL(fileURLWithPath: path))) != nil
}
// MARK: selftest
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
+}