@arach/lattices 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +28 -28
  2. package/app/Sources/ActionRow.swift +61 -0
  3. package/app/Sources/App.swift +1 -40
  4. package/app/Sources/AppDelegate.swift +154 -24
  5. package/app/Sources/CheatSheetHUD.swift +1 -0
  6. package/app/Sources/CommandModeState.swift +40 -19
  7. package/app/Sources/CommandModeView.swift +27 -2
  8. package/app/Sources/DaemonServer.swift +8 -0
  9. package/app/Sources/DiagnosticLog.swift +19 -1
  10. package/app/Sources/EventBus.swift +1 -0
  11. package/app/Sources/HotkeyManager.swift +1 -0
  12. package/app/Sources/HotkeyStore.swift +9 -1
  13. package/app/Sources/LatticesApi.swift +210 -0
  14. package/app/Sources/MainView.swift +46 -86
  15. package/app/Sources/MainWindow.swift +13 -0
  16. package/app/Sources/OcrModel.swift +309 -0
  17. package/app/Sources/OcrStore.swift +295 -0
  18. package/app/Sources/OmniSearchState.swift +283 -0
  19. package/app/Sources/OmniSearchView.swift +288 -0
  20. package/app/Sources/OmniSearchWindow.swift +105 -0
  21. package/app/Sources/PaletteCommand.swift +11 -1
  22. package/app/Sources/PermissionChecker.swift +12 -2
  23. package/app/Sources/Preferences.swift +44 -0
  24. package/app/Sources/ScreenMapState.swift +7 -17
  25. package/app/Sources/ScreenMapView.swift +3 -0
  26. package/app/Sources/SettingsView.swift +534 -122
  27. package/app/Sources/Theme.swift +39 -0
  28. package/app/Sources/WindowTiler.swift +59 -56
  29. package/bin/lattices-app.js +23 -7
  30. package/bin/lattices.js +123 -0
  31. package/docs/api.md +390 -249
  32. package/docs/app.md +75 -28
  33. package/docs/concepts.md +45 -136
  34. package/docs/config.md +8 -7
  35. package/docs/layers.md +16 -18
  36. package/docs/ocr.md +185 -0
  37. package/docs/overview.md +39 -34
  38. package/docs/quickstart.md +34 -35
  39. package/package.json +6 -2
@@ -0,0 +1,283 @@
1
+ import AppKit
2
+ import Combine
3
+ import Foundation
4
+
5
+ // MARK: - Result Types
6
+
7
+ enum OmniResultKind: String {
8
+ case window
9
+ case project
10
+ case session
11
+ case process
12
+ case ocrContent
13
+ }
14
+
15
+ struct OmniResult: Identifiable {
16
+ let id = UUID()
17
+ let kind: OmniResultKind
18
+ let title: String
19
+ let subtitle: String
20
+ let icon: String
21
+ let score: Int // higher = better match
22
+ let action: () -> Void
23
+
24
+ /// Group label for display
25
+ var groupLabel: String {
26
+ switch kind {
27
+ case .window: return "Windows"
28
+ case .project: return "Projects"
29
+ case .session: return "Sessions"
30
+ case .process: return "Processes"
31
+ case .ocrContent: return "Screen Text"
32
+ }
33
+ }
34
+ }
35
+
36
+ // MARK: - Activity Summary
37
+
38
+ struct ActivitySummary {
39
+ struct AppWindowCount: Identifiable {
40
+ let id: String
41
+ let appName: String
42
+ let count: Int
43
+ }
44
+
45
+ struct SessionInfo: Identifiable {
46
+ let id: String
47
+ let name: String
48
+ let paneCount: Int
49
+ let attached: Bool
50
+ }
51
+
52
+ let windowsByApp: [AppWindowCount]
53
+ let totalWindows: Int
54
+ let sessions: [SessionInfo]
55
+ let interestingProcesses: [ProcessEntry]
56
+ let lastOcrScan: Date?
57
+ let ocrWindowCount: Int
58
+ }
59
+
60
+ // MARK: - State
61
+
62
+ final class OmniSearchState: ObservableObject {
63
+ @Published var query: String = ""
64
+ @Published var results: [OmniResult] = []
65
+ @Published var selectedIndex: Int = 0
66
+ @Published var activitySummary: ActivitySummary?
67
+
68
+ private var cancellables = Set<AnyCancellable>()
69
+ private var debounceTimer: AnyCancellable?
70
+
71
+ init() {
72
+ // Debounce search by 150ms
73
+ debounceTimer = $query
74
+ .debounce(for: .milliseconds(150), scheduler: RunLoop.main)
75
+ .sink { [weak self] q in
76
+ if q.isEmpty {
77
+ self?.results = []
78
+ self?.refreshSummary()
79
+ } else {
80
+ self?.search(q)
81
+ }
82
+ }
83
+
84
+ refreshSummary()
85
+ }
86
+
87
+ // MARK: - Search
88
+
89
+ private func search(_ query: String) {
90
+ let q = query.lowercased()
91
+ var all: [OmniResult] = []
92
+
93
+ // Windows
94
+ let desktop = DesktopModel.shared
95
+ for win in desktop.allWindows() {
96
+ let score = scoreMatch(q, against: [win.app, win.title])
97
+ if score > 0 {
98
+ let wid = win.wid
99
+ let pid = win.pid
100
+ all.append(OmniResult(
101
+ kind: .window,
102
+ title: win.app,
103
+ subtitle: win.title.isEmpty ? "Window \(win.wid)" : win.title,
104
+ icon: "macwindow",
105
+ score: score
106
+ ) {
107
+ WindowTiler.focusWindow(wid: wid, pid: pid)
108
+ })
109
+ }
110
+ }
111
+
112
+ // Projects
113
+ let scanner = ProjectScanner.shared
114
+ for project in scanner.projects {
115
+ let score = scoreMatch(q, against: [project.name, project.path])
116
+ if score > 0 {
117
+ let proj = project
118
+ all.append(OmniResult(
119
+ kind: .project,
120
+ title: project.name,
121
+ subtitle: project.path,
122
+ icon: "folder",
123
+ score: score
124
+ ) {
125
+ SessionManager.launch(project: proj)
126
+ })
127
+ }
128
+ }
129
+
130
+ // Tmux Sessions
131
+ let tmux = TmuxModel.shared
132
+ for session in tmux.sessions {
133
+ let paneCommands = session.panes.map(\.currentCommand)
134
+ let score = scoreMatch(q, against: [session.name] + paneCommands)
135
+ if score > 0 {
136
+ let name = session.name
137
+ all.append(OmniResult(
138
+ kind: .session,
139
+ title: session.name,
140
+ subtitle: "\(session.windowCount) windows, \(session.panes.count) panes\(session.attached ? " (attached)" : "")",
141
+ icon: "terminal",
142
+ score: score
143
+ ) {
144
+ let terminal = Preferences.shared.terminal
145
+ terminal.focusOrAttach(session: name)
146
+ })
147
+ }
148
+ }
149
+
150
+ // Processes
151
+ let processes = ProcessModel.shared
152
+ for proc in processes.interesting {
153
+ let score = scoreMatch(q, against: [proc.comm, proc.args, proc.cwd ?? ""])
154
+ if score > 0 {
155
+ all.append(OmniResult(
156
+ kind: .process,
157
+ title: proc.comm,
158
+ subtitle: proc.cwd ?? proc.args,
159
+ icon: "gearshape",
160
+ score: score
161
+ ) {
162
+ // No direct action for processes — just informational
163
+ })
164
+ }
165
+ }
166
+
167
+ // OCR content
168
+ let ocr = OcrModel.shared
169
+ for (_, result) in ocr.results {
170
+ let ocrScore = scoreOcr(q, fullText: result.fullText)
171
+ if ocrScore > 0 {
172
+ let wid = result.wid
173
+ let pid = desktop.windows[wid]?.pid ?? 0
174
+ // Find matching line for subtitle
175
+ let matchLine = result.texts
176
+ .first { $0.text.lowercased().contains(q) }?
177
+ .text ?? String(result.fullText.prefix(80))
178
+ all.append(OmniResult(
179
+ kind: .ocrContent,
180
+ title: "\(result.app) — \(result.title)",
181
+ subtitle: matchLine,
182
+ icon: "doc.text.magnifyingglass",
183
+ score: ocrScore
184
+ ) {
185
+ WindowTiler.focusWindow(wid: wid, pid: pid)
186
+ })
187
+ }
188
+ }
189
+
190
+ // Sort by score descending
191
+ all.sort { $0.score > $1.score }
192
+
193
+ results = all
194
+ selectedIndex = 0
195
+ }
196
+
197
+ // MARK: - Scoring
198
+
199
+ private func scoreMatch(_ query: String, against fields: [String]) -> Int {
200
+ var best = 0
201
+ for field in fields {
202
+ let lower = field.lowercased()
203
+ if lower == query {
204
+ best = max(best, 100) // exact
205
+ } else if lower.hasPrefix(query) {
206
+ best = max(best, 80) // prefix
207
+ } else if lower.contains(query) {
208
+ best = max(best, 60) // contains
209
+ }
210
+ }
211
+ return best
212
+ }
213
+
214
+ private func scoreOcr(_ query: String, fullText: String) -> Int {
215
+ let lower = fullText.lowercased()
216
+ if lower.contains(query) { return 40 }
217
+ return 0
218
+ }
219
+
220
+ // MARK: - Navigation
221
+
222
+ func moveSelection(_ delta: Int) {
223
+ guard !results.isEmpty else { return }
224
+ selectedIndex = max(0, min(results.count - 1, selectedIndex + delta))
225
+ }
226
+
227
+ func activateSelected() {
228
+ guard selectedIndex >= 0, selectedIndex < results.count else { return }
229
+ results[selectedIndex].action()
230
+ }
231
+
232
+ // MARK: - Activity Summary
233
+
234
+ func refreshSummary() {
235
+ let desktop = DesktopModel.shared
236
+ let windows = desktop.allWindows()
237
+
238
+ // Group by app
239
+ var appCounts: [String: Int] = [:]
240
+ for win in windows {
241
+ appCounts[win.app, default: 0] += 1
242
+ }
243
+ let windowsByApp = appCounts
244
+ .sorted { $0.value > $1.value }
245
+ .map { ActivitySummary.AppWindowCount(id: $0.key, appName: $0.key, count: $0.value) }
246
+
247
+ // Sessions
248
+ let sessions = TmuxModel.shared.sessions.map {
249
+ ActivitySummary.SessionInfo(
250
+ id: $0.id,
251
+ name: $0.name,
252
+ paneCount: $0.panes.count,
253
+ attached: $0.attached
254
+ )
255
+ }
256
+
257
+ // Processes
258
+ let procs = ProcessModel.shared.interesting
259
+
260
+ // OCR info
261
+ let ocrResults = OcrModel.shared.results
262
+ let lastScan: Date? = ocrResults.values.map(\.timestamp).max()
263
+
264
+ activitySummary = ActivitySummary(
265
+ windowsByApp: windowsByApp,
266
+ totalWindows: windows.count,
267
+ sessions: sessions,
268
+ interestingProcesses: procs,
269
+ lastOcrScan: lastScan,
270
+ ocrWindowCount: ocrResults.count
271
+ )
272
+ }
273
+
274
+ /// Grouped results for display
275
+ var groupedResults: [(String, [OmniResult])] {
276
+ let groups = Dictionary(grouping: results) { $0.groupLabel }
277
+ let order: [String] = ["Windows", "Projects", "Sessions", "Processes", "Screen Text"]
278
+ return order.compactMap { key in
279
+ guard let items = groups[key], !items.isEmpty else { return nil }
280
+ return (key, items)
281
+ }
282
+ }
283
+ }
@@ -0,0 +1,288 @@
1
+ import SwiftUI
2
+
3
+ struct OmniSearchView: View {
4
+ @ObservedObject var state: OmniSearchState
5
+ var onDismiss: () -> Void
6
+
7
+ @FocusState private var searchFocused: Bool
8
+
9
+ var body: some View {
10
+ VStack(spacing: 0) {
11
+ // Search field
12
+ HStack(spacing: 8) {
13
+ Image(systemName: "magnifyingglass")
14
+ .foregroundColor(Palette.textMuted)
15
+ .font(.system(size: 13))
16
+
17
+ TextField("Search windows, projects, sessions...", text: $state.query)
18
+ .textFieldStyle(.plain)
19
+ .font(Typo.mono(14))
20
+ .foregroundColor(Palette.text)
21
+ .focused($searchFocused)
22
+
23
+ if !state.query.isEmpty {
24
+ Button { state.query = "" } label: {
25
+ Image(systemName: "xmark.circle.fill")
26
+ .foregroundColor(Palette.textMuted)
27
+ .font(.system(size: 12))
28
+ }
29
+ .buttonStyle(.plain)
30
+ }
31
+ }
32
+ .padding(.horizontal, 14)
33
+ .padding(.vertical, 12)
34
+ .background(Palette.surface)
35
+
36
+ Rectangle()
37
+ .fill(Palette.border)
38
+ .frame(height: 0.5)
39
+
40
+ // Content
41
+ if state.query.isEmpty {
42
+ summaryView
43
+ } else if state.results.isEmpty {
44
+ emptyResults
45
+ } else {
46
+ resultsView
47
+ }
48
+ }
49
+ .frame(minWidth: 520, idealWidth: 520, maxWidth: 700, minHeight: 360, idealHeight: 480, maxHeight: 600)
50
+ .background(PanelBackground())
51
+ .preferredColorScheme(.dark)
52
+ .onAppear {
53
+ searchFocused = true
54
+ state.refreshSummary()
55
+ }
56
+ }
57
+
58
+ // MARK: - Results
59
+
60
+ private var resultsView: some View {
61
+ ScrollViewReader { proxy in
62
+ ScrollView {
63
+ LazyVStack(alignment: .leading, spacing: 2) {
64
+ var flatIndex = 0
65
+ ForEach(state.groupedResults, id: \.0) { group, items in
66
+ // Group header
67
+ Text(group.uppercased())
68
+ .font(Typo.caption(9))
69
+ .foregroundColor(Palette.textMuted)
70
+ .padding(.horizontal, 14)
71
+ .padding(.top, 8)
72
+ .padding(.bottom, 2)
73
+
74
+ ForEach(items) { item in
75
+ let idx = flatIndex
76
+ let _ = { flatIndex += 1 }()
77
+ resultRow(item, index: idx)
78
+ .id(item.id)
79
+ }
80
+ }
81
+ }
82
+ .padding(.vertical, 4)
83
+ }
84
+ .onChange(of: state.selectedIndex) { newVal in
85
+ if newVal < state.results.count {
86
+ let item = state.results[newVal]
87
+ withAnimation(.easeOut(duration: 0.1)) {
88
+ proxy.scrollTo(item.id, anchor: .center)
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ private func resultRow(_ item: OmniResult, index: Int) -> some View {
96
+ let isSelected = index == state.selectedIndex
97
+ return Button {
98
+ item.action()
99
+ onDismiss()
100
+ } label: {
101
+ HStack(spacing: 10) {
102
+ Image(systemName: item.icon)
103
+ .font(.system(size: 11, weight: .medium))
104
+ .foregroundColor(isSelected ? Palette.text : Palette.textDim)
105
+ .frame(width: 16)
106
+
107
+ VStack(alignment: .leading, spacing: 1) {
108
+ Text(item.title)
109
+ .font(Typo.mono(12))
110
+ .foregroundColor(isSelected ? Palette.text : Palette.textDim)
111
+ .lineLimit(1)
112
+
113
+ Text(item.subtitle)
114
+ .font(Typo.mono(10))
115
+ .foregroundColor(Palette.textMuted)
116
+ .lineLimit(1)
117
+ }
118
+
119
+ Spacer()
120
+
121
+ Text(item.kind.rawValue)
122
+ .font(Typo.mono(9))
123
+ .foregroundColor(Palette.textMuted)
124
+ .padding(.horizontal, 5)
125
+ .padding(.vertical, 2)
126
+ .background(
127
+ RoundedRectangle(cornerRadius: 3)
128
+ .fill(Palette.surface)
129
+ )
130
+ }
131
+ .padding(.horizontal, 14)
132
+ .padding(.vertical, 6)
133
+ .background(
134
+ RoundedRectangle(cornerRadius: 5)
135
+ .fill(isSelected ? Palette.surfaceHov : Color.clear)
136
+ )
137
+ .contentShape(Rectangle())
138
+ }
139
+ .buttonStyle(.plain)
140
+ }
141
+
142
+ // MARK: - Empty Results
143
+
144
+ private var emptyResults: some View {
145
+ VStack(spacing: 12) {
146
+ Spacer()
147
+ Image(systemName: "magnifyingglass")
148
+ .font(.system(size: 24, weight: .light))
149
+ .foregroundColor(Palette.textMuted)
150
+ Text("No results for \"\(state.query)\"")
151
+ .font(Typo.mono(12))
152
+ .foregroundColor(Palette.textDim)
153
+ Spacer()
154
+ }
155
+ }
156
+
157
+ // MARK: - Activity Summary
158
+
159
+ private var summaryView: some View {
160
+ ScrollView {
161
+ VStack(alignment: .leading, spacing: 14) {
162
+ if let summary = state.activitySummary {
163
+ // Windows by app
164
+ summarySection("WINDOWS", icon: "macwindow", count: summary.totalWindows) {
165
+ ForEach(summary.windowsByApp) { app in
166
+ HStack {
167
+ Text(app.appName)
168
+ .font(Typo.mono(11))
169
+ .foregroundColor(Palette.textDim)
170
+ .lineLimit(1)
171
+ Spacer()
172
+ Text("\(app.count)")
173
+ .font(Typo.monoBold(11))
174
+ .foregroundColor(Palette.text)
175
+ }
176
+ }
177
+ }
178
+
179
+ // Sessions
180
+ if !summary.sessions.isEmpty {
181
+ summarySection("TMUX SESSIONS", icon: "terminal", count: summary.sessions.count) {
182
+ ForEach(summary.sessions) { session in
183
+ HStack {
184
+ Circle()
185
+ .fill(session.attached ? Palette.running : Palette.textMuted)
186
+ .frame(width: 6, height: 6)
187
+ Text(session.name)
188
+ .font(Typo.mono(11))
189
+ .foregroundColor(Palette.textDim)
190
+ Spacer()
191
+ Text("\(session.paneCount) panes")
192
+ .font(Typo.mono(10))
193
+ .foregroundColor(Palette.textMuted)
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ // Processes
200
+ if !summary.interestingProcesses.isEmpty {
201
+ summarySection("PROCESSES", icon: "gearshape", count: summary.interestingProcesses.count) {
202
+ ForEach(Array(summary.interestingProcesses.prefix(10).enumerated()), id: \.offset) { _, proc in
203
+ HStack {
204
+ Text(proc.comm)
205
+ .font(Typo.monoBold(11))
206
+ .foregroundColor(Palette.textDim)
207
+ if let cwd = proc.cwd {
208
+ Text(cwd.replacingOccurrences(of: NSHomeDirectory(), with: "~"))
209
+ .font(Typo.mono(10))
210
+ .foregroundColor(Palette.textMuted)
211
+ .lineLimit(1)
212
+ }
213
+ Spacer()
214
+ }
215
+ }
216
+ }
217
+ }
218
+
219
+ // OCR info
220
+ if summary.ocrWindowCount > 0 {
221
+ HStack(spacing: 6) {
222
+ Image(systemName: "doc.text.magnifyingglass")
223
+ .font(.system(size: 10))
224
+ .foregroundColor(Palette.textMuted)
225
+ Text("OCR: \(summary.ocrWindowCount) windows scanned")
226
+ .font(Typo.mono(10))
227
+ .foregroundColor(Palette.textMuted)
228
+ if let t = summary.lastOcrScan {
229
+ Spacer()
230
+ Text(relativeTime(t))
231
+ .font(Typo.mono(9))
232
+ .foregroundColor(Palette.textMuted)
233
+ }
234
+ }
235
+ .padding(.horizontal, 14)
236
+ }
237
+ } else {
238
+ Text("Loading...")
239
+ .font(Typo.mono(11))
240
+ .foregroundColor(Palette.textMuted)
241
+ .padding(14)
242
+ }
243
+ }
244
+ .padding(.vertical, 10)
245
+ }
246
+ }
247
+
248
+ private func summarySection<Content: View>(
249
+ _ title: String,
250
+ icon: String,
251
+ count: Int,
252
+ @ViewBuilder content: () -> Content
253
+ ) -> some View {
254
+ VStack(alignment: .leading, spacing: 6) {
255
+ HStack(spacing: 6) {
256
+ Image(systemName: icon)
257
+ .font(.system(size: 10, weight: .medium))
258
+ .foregroundColor(Palette.textMuted)
259
+ Text(title)
260
+ .font(Typo.caption(9))
261
+ .foregroundColor(Palette.textMuted)
262
+ Text("\(count)")
263
+ .font(Typo.monoBold(9))
264
+ .foregroundColor(Palette.running)
265
+ .padding(.horizontal, 4)
266
+ .padding(.vertical, 1)
267
+ .background(
268
+ RoundedRectangle(cornerRadius: 3)
269
+ .fill(Palette.running.opacity(0.12))
270
+ )
271
+ Spacer()
272
+ }
273
+ .padding(.horizontal, 14)
274
+
275
+ VStack(spacing: 3) {
276
+ content()
277
+ }
278
+ .padding(.horizontal, 14)
279
+ }
280
+ }
281
+
282
+ private func relativeTime(_ date: Date) -> String {
283
+ let seconds = Int(Date().timeIntervalSince(date))
284
+ if seconds < 60 { return "\(seconds)s ago" }
285
+ if seconds < 3600 { return "\(seconds / 60)m ago" }
286
+ return "\(seconds / 3600)h ago"
287
+ }
288
+ }
@@ -0,0 +1,105 @@
1
+ import AppKit
2
+ import SwiftUI
3
+
4
+ final class OmniSearchWindow {
5
+ static let shared = OmniSearchWindow()
6
+
7
+ private var panel: NSPanel?
8
+ private var keyMonitor: Any?
9
+ private var state: OmniSearchState?
10
+
11
+ var isVisible: Bool { panel?.isVisible ?? false }
12
+
13
+ func toggle() {
14
+ if isVisible {
15
+ dismiss()
16
+ } else {
17
+ show()
18
+ }
19
+ }
20
+
21
+ func show() {
22
+ if let p = panel, p.isVisible {
23
+ p.makeKeyAndOrderFront(nil)
24
+ NSApp.activate(ignoringOtherApps: true)
25
+ return
26
+ }
27
+
28
+ // Fresh state each time
29
+ let searchState = OmniSearchState()
30
+ state = searchState
31
+
32
+ let view = OmniSearchView(state: searchState) { [weak self] in
33
+ self?.dismiss()
34
+ }
35
+ .preferredColorScheme(.dark)
36
+
37
+ let hosting = NSHostingController(rootView: view)
38
+ hosting.preferredContentSize = NSSize(width: 520, height: 480)
39
+
40
+ let p = NSPanel(
41
+ contentRect: NSRect(x: 0, y: 0, width: 520, height: 480),
42
+ styleMask: [.titled, .closable, .resizable, .utilityWindow, .nonactivatingPanel],
43
+ backing: .buffered,
44
+ defer: false
45
+ )
46
+ p.contentViewController = hosting
47
+ p.title = "Omni Search"
48
+ p.titlebarAppearsTransparent = true
49
+ p.titleVisibility = .hidden
50
+ p.isMovableByWindowBackground = true
51
+ p.level = .floating
52
+ p.isOpaque = false
53
+ p.backgroundColor = NSColor(red: 0.11, green: 0.11, blue: 0.12, alpha: 1.0)
54
+ p.hasShadow = true
55
+ p.appearance = NSAppearance(named: .darkAqua)
56
+ p.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary]
57
+ p.minSize = NSSize(width: 400, height: 300)
58
+ p.maxSize = NSSize(width: 700, height: 700)
59
+
60
+ // Center on screen
61
+ if let screen = NSScreen.main {
62
+ let visibleFrame = screen.visibleFrame
63
+ let x = visibleFrame.midX - 260
64
+ let y = visibleFrame.midY + 60 // slightly above center
65
+ p.setFrameOrigin(NSPoint(x: x, y: y))
66
+ }
67
+
68
+ p.makeKeyAndOrderFront(nil)
69
+ NSApp.activate(ignoringOtherApps: true)
70
+ panel = p
71
+
72
+ // Key monitor: Escape → dismiss, arrow keys → navigate, Enter → activate
73
+ keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
74
+ guard self?.panel?.isKeyWindow == true else { return event }
75
+
76
+ switch event.keyCode {
77
+ case 53: // Escape
78
+ self?.dismiss()
79
+ return nil
80
+ case 125: // ↓
81
+ self?.state?.moveSelection(1)
82
+ return nil
83
+ case 126: // ↑
84
+ self?.state?.moveSelection(-1)
85
+ return nil
86
+ case 36: // Enter
87
+ self?.state?.activateSelected()
88
+ self?.dismiss()
89
+ return nil
90
+ default:
91
+ return event
92
+ }
93
+ }
94
+ }
95
+
96
+ func dismiss() {
97
+ panel?.orderOut(nil)
98
+ panel = nil
99
+ state = nil
100
+ if let monitor = keyMonitor {
101
+ NSEvent.removeMonitor(monitor)
102
+ keyMonitor = nil
103
+ }
104
+ }
105
+ }
@@ -364,9 +364,19 @@ enum CommandBuilder {
364
364
  }
365
365
  ))
366
366
 
367
+ commands.append(PaletteCommand(
368
+ id: "app-windows-list",
369
+ title: "Windows List",
370
+ subtitle: "Browse all windows across displays",
371
+ icon: "rectangle.split.2x1",
372
+ category: .app,
373
+ badge: nil,
374
+ action: { CommandModeWindow.shared.show() }
375
+ ))
376
+
367
377
  commands.append(PaletteCommand(
368
378
  id: "app-screen-map",
369
- title: "Screen Map",
379
+ title: "Window Map",
370
380
  subtitle: "Visual window editor",
371
381
  icon: "rectangle.3.group",
372
382
  category: .app,