From f4a7346fd32f906ccc814364462c18afb3f10616 Mon Sep 17 00:00:00 2001 From: Igor Soarez Date: Thu, 22 Oct 2026 18:01:04 +0300 Subject: 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. --- README.md | 15 +- menubar/hotkey.swift | 25 ++- menubar/main.swift | 57 +++++- menubar/model.swift | 12 +- menubar/panel.swift | 108 ++++++----- menubar/search.swift | 492 +++++++++++++++++++++++++++++++++++++++++++++++++++ tests/e2e.sh | 18 ++ 7 files changed, 667 insertions(+), 60 deletions(-) create mode 100644 menubar/search.swift diff --git a/README.md b/README.md index 9d54428..fb843ba 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,16 @@ than a poll is still a completion. (with a "Show all / Show less" toggle for the full list). The panel is always dark, in herdr's palette, so it reads the same over any desktop. - **Jump shortcut**: **⌃⌥⌘U** jumps to the top waiting agent from anywhere, - like clicking the tmux status line. Both shortcuts live in - `~/.config/dingger/config.toml` (menu bar, Settings…), reloaded on save. + like clicking the tmux status line. +- **Agent search**: **⌃⌥⇧⌘U** opens a Spotlight-style search over every + agent. Type to fuzzy-filter (each word must match, letters in order, the + name, session, window, harness, status, or directory); ↑↓ or ⌃N ⌃P / ⌃J ⌃K + select, ↩ or a click jumps, esc closes, ⌃U clears. With nothing typed, + agents are in the menu's order, so ↩ jumps to the top waiting agent. It + takes the keyboard without activating dingger, so focus returns where it + was. +- All shortcuts live in `~/.config/dingger/config.toml` (menu bar, + Settings…), reloaded on save. - **tmux status line** (`packaging/tmux/dingger.tmux`): the same summary after the clock. Clicking it jumps to the top waiting agent (the menu's top row), skipping the window you already look at. @@ -108,7 +116,8 @@ tests/e2e.sh # end to end against an isolated tmux server over the hook protocol, and a real tmux client whose focus it controls. It asserts every status transition, attendance by navigation, both click paths (including the real menu bar binary), the floating panel's visibility and -layout rules and content (compact, full, toggle), the status-line segment, latency with a +layout rules and content (compact, full, toggle), agent search (matching, +ranking, selection), the status-line segment, latency with a deliberately slow notifier, and state surviving a daemon restart. It never touches your tmux server. 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") @@ -22,6 +23,25 @@ struct Settings: Equatable { # 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" + # 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" @@ -31,7 +51,6 @@ struct Settings: Equatable { """ - /// 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 [--full] // render the panel offscreen (after a different render, to // catch stale content) over white and black, then exit +// --print-search +// print what agent search would list for , then exit +// --snapshot-search [] +// render agent search offscreen, then exit // --click perform a row click for , 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.. score[i][j] { score[i][j] = candidate; from[i][j] = k } + } + } + } + guard let end = (0.. 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() + 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 { results[offset..= 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 +} diff --git a/tests/e2e.sh b/tests/e2e.sh index 1a5cba0..983912c 100755 --- a/tests/e2e.sh +++ b/tests/e2e.sh @@ -18,6 +18,9 @@ set -u ROOT=$(cd "$(dirname "$0")/.." && pwd) DINGGER="${1:-$ROOT/target/release/dingger}" MENUBAR="${2:-$ROOT/target/dingger-menu}" +# tmux hooks run these from other directories: make them absolute. +case "$DINGGER" in /*) ;; *) DINGGER="$PWD/$DINGGER" ;; esac +case "$MENUBAR" in /*) ;; *) MENUBAR="$PWD/$MENUBAR" ;; esac SOCK=dingger-e2e DIR=$(mktemp -d /tmp/dingger-e2e.XXXXXX) HOOK="$DIR/agent.sock" @@ -327,6 +330,21 @@ expect "A2 done" "opt $A2 @agent_state" done launchd_env "$DINGGER" focus expect "dingger focus (jump shortcut) jumps, under launchd's PATH" "client_window" wa2 +# --- 15. agent search ---------------------------------------------------------- +echo "15. agent search" +if [ -x "$MENUBAR" ]; then + mb() { DINGGER_TMUX_SOCKET="$SOCK" DINGGER_BIN="$DINGGER" "$MENUBAR" "$@"; } + expect "empty query lists every agent" "mb --print-search '' | tail -1 | cut -d' ' -f1,2" "4 agents" + expect "fuzzy name match, selected" "mb --print-search 'rvw42' | head -1 | cut -f1,2" "› ○ idle Review PR 42" + expect "every word must match" "mb --print-search 'deploy 42' | head -1" "No agents match 'deploy 42'" + expect "finds an agent by its window" "mb --print-search wc1 | head -1 | cut -f3" "c · wc1 · pi" + expect "finds agents by directory" "mb --print-search \"$(basename "$DIR")\" | tail -1 | cut -d' ' -f1,2" "4 agents" + hook "$A2" working + expect "status word ranks first" "mb --print-search working | head -1 | cut -f1" "› ◐ working" +else + fail "menu bar binary not found at $MENUBAR" +fi + echo echo "$PASSES passed, $FAILS failed" [ "$FAILS" -eq 0 ] || { echo "--- daemon log tail"; tail -30 "$DIR/daemon.log"; exit 1; } -- cgit v1.2.3