@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
@@ -67,18 +67,27 @@ final class DiagnosticWindow {
67
67
  static let shared = DiagnosticWindow()
68
68
 
69
69
  private var window: NSWindow?
70
+ private var keyMonitor: Any?
70
71
  private let log = DiagnosticLog.shared
71
72
 
72
73
  var isVisible: Bool { window?.isVisible ?? false }
73
74
 
74
75
  func toggle() {
75
76
  if let w = window, w.isVisible {
76
- w.orderOut(nil)
77
+ dismiss()
77
78
  } else {
78
79
  show()
79
80
  }
80
81
  }
81
82
 
83
+ func dismiss() {
84
+ window?.orderOut(nil)
85
+ if let monitor = keyMonitor {
86
+ NSEvent.removeMonitor(monitor)
87
+ keyMonitor = nil
88
+ }
89
+ }
90
+
82
91
  func show() {
83
92
  if let w = window {
84
93
  w.orderFrontRegardless()
@@ -119,6 +128,15 @@ final class DiagnosticWindow {
119
128
  w.orderFrontRegardless()
120
129
  window = w
121
130
 
131
+ // Escape key → dismiss
132
+ keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
133
+ guard event.keyCode == 53,
134
+ let win = self?.window,
135
+ event.window === win || win.isKeyWindow else { return event }
136
+ self?.dismiss()
137
+ return nil
138
+ }
139
+
122
140
  // Startup log
123
141
  let diag = DiagnosticLog.shared
124
142
  diag.info("Diagnostics opened")
@@ -5,6 +5,7 @@ enum ModelEvent {
5
5
  case tmuxChanged(sessions: [TmuxSession])
6
6
  case layerSwitched(index: Int)
7
7
  case processesChanged(interesting: [Int])
8
+ case ocrScanComplete(windowCount: Int, totalBlocks: Int)
8
9
  }
9
10
 
10
11
  final class EventBus {
@@ -1,5 +1,6 @@
1
1
  import Carbon
2
2
  import AppKit
3
+ import Foundation
3
4
 
4
5
  /// Global callback registry keyed by hotkey ID
5
6
  private var hotkeyCallbacks: [UInt32: () -> Void] = [:]
@@ -18,6 +18,8 @@ enum HotkeyAction: String, CaseIterable, Codable {
18
18
  case screenMap
19
19
  case bezel
20
20
  case cheatSheet
21
+ case desktopInventory
22
+ case omniSearch
21
23
  // Layers
22
24
  case layer1, layer2, layer3, layer4, layer5, layer6, layer7, layer8, layer9
23
25
  // Tiling
@@ -32,6 +34,8 @@ enum HotkeyAction: String, CaseIterable, Codable {
32
34
  case .screenMap: return "Screen Map"
33
35
  case .bezel: return "Window Bezel"
34
36
  case .cheatSheet: return "Cheat Sheet"
37
+ case .desktopInventory: return "Desktop Inventory"
38
+ case .omniSearch: return "Omni Search"
35
39
  case .layer1: return "Layer 1"
36
40
  case .layer2: return "Layer 2"
37
41
  case .layer3: return "Layer 3"
@@ -60,7 +64,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
60
64
 
61
65
  var group: HotkeyGroup {
62
66
  switch self {
63
- case .palette, .screenMap, .bezel, .cheatSheet: return .app
67
+ case .palette, .screenMap, .bezel, .cheatSheet, .desktopInventory, .omniSearch: return .app
64
68
  case .layer1, .layer2, .layer3, .layer4, .layer5,
65
69
  .layer6, .layer7, .layer8, .layer9: return .layers
66
70
  default: return .tiling
@@ -73,6 +77,8 @@ enum HotkeyAction: String, CaseIterable, Codable {
73
77
  case .screenMap: return 200
74
78
  case .bezel: return 201
75
79
  case .cheatSheet: return 202
80
+ case .desktopInventory: return 203
81
+ case .omniSearch: return 204
76
82
  case .layer1: return 101
77
83
  case .layer2: return 102
78
84
  case .layer3: return 103
@@ -192,6 +198,8 @@ class HotkeyStore: ObservableObject {
192
198
  bind(.screenMap, 18, hyper) // Hyper+1
193
199
  bind(.bezel, 19, hyper) // Hyper+2
194
200
  bind(.cheatSheet, 20, hyper) // Hyper+3
201
+ bind(.desktopInventory, 21, hyper) // Hyper+4
202
+ bind(.omniSearch, 23, hyper) // Hyper+5
195
203
 
196
204
  // Layers: Cmd+Option+1-9
197
205
  let layerKeyCodes: [UInt32] = [18, 19, 20, 21, 23, 22, 26, 28, 25]
@@ -244,6 +244,36 @@ final class LatticesApi {
244
244
  Field(name: "displayName", type: "string", required: true, description: "Best display name (session > tab title > tty)"),
245
245
  ]))
246
246
 
247
+ api.model(ApiModel(name: "OcrResult", fields: [
248
+ Field(name: "wid", type: "int", required: true, description: "Window ID"),
249
+ Field(name: "app", type: "string", required: true, description: "Application name"),
250
+ Field(name: "title", type: "string", required: true, description: "Window title"),
251
+ Field(name: "frame", type: "Frame", required: true, description: "Window frame"),
252
+ Field(name: "fullText", type: "string", required: true, description: "All recognized text"),
253
+ Field(name: "blocks", type: "[OcrBlock]", required: true, description: "Individual text blocks with position/confidence"),
254
+ Field(name: "timestamp", type: "double", required: true, description: "Scan timestamp (Unix)"),
255
+ ]))
256
+
257
+ api.model(ApiModel(name: "OcrBlock", fields: [
258
+ Field(name: "text", type: "string", required: true, description: "Recognized text"),
259
+ Field(name: "confidence", type: "double", required: true, description: "Recognition confidence 0-1"),
260
+ Field(name: "x", type: "double", required: true, description: "Normalized bounding box x"),
261
+ Field(name: "y", type: "double", required: true, description: "Normalized bounding box y"),
262
+ Field(name: "w", type: "double", required: true, description: "Normalized bounding box width"),
263
+ Field(name: "h", type: "double", required: true, description: "Normalized bounding box height"),
264
+ ]))
265
+
266
+ api.model(ApiModel(name: "OcrSearchResult", fields: [
267
+ Field(name: "id", type: "int", required: true, description: "Database row ID"),
268
+ Field(name: "wid", type: "int", required: true, description: "Window ID"),
269
+ Field(name: "app", type: "string", required: true, description: "Application name"),
270
+ Field(name: "title", type: "string", required: true, description: "Window title"),
271
+ Field(name: "frame", type: "Frame", required: true, description: "Window frame at scan time"),
272
+ Field(name: "fullText", type: "string", required: true, description: "Full recognized text"),
273
+ Field(name: "snippet", type: "string", required: true, description: "Highlighted snippet (FTS5)"),
274
+ Field(name: "timestamp", type: "double", required: true, description: "Scan timestamp (Unix)"),
275
+ ]))
276
+
247
277
  api.model(ApiModel(name: "DaemonStatus", fields: [
248
278
  Field(name: "uptime", type: "double", required: true, description: "Seconds since daemon started"),
249
279
  Field(name: "clientCount", type: "int", required: true, description: "Connected WebSocket clients"),
@@ -283,6 +313,63 @@ final class LatticesApi {
283
313
  }
284
314
  ))
285
315
 
316
+ api.register(Endpoint(
317
+ method: "windows.search",
318
+ description: "Search windows by title, app, and OCR content",
319
+ access: .read,
320
+ params: [
321
+ Param(name: "query", type: "string", required: true, description: "Search text"),
322
+ Param(name: "ocr", type: "bool", required: false, description: "Include OCR content (default true)"),
323
+ Param(name: "limit", type: "int", required: false, description: "Max results (default 50)"),
324
+ ],
325
+ returns: .array(model: "Window"),
326
+ handler: { params in
327
+ guard let query = params?["query"]?.stringValue?.lowercased(), !query.isEmpty else {
328
+ throw RouterError.missingParam("query")
329
+ }
330
+ let includeOcr = params?["ocr"]?.boolValue ?? true
331
+ let limit = params?["limit"]?.intValue ?? 50
332
+ let ocrResults = OcrModel.shared.results
333
+
334
+ var matches: [JSON] = []
335
+ for entry in DesktopModel.shared.allWindows() {
336
+ let matchesApp = entry.app.lowercased().contains(query)
337
+ let matchesTitle = entry.title.lowercased().contains(query)
338
+ let matchesSession = entry.latticesSession?.lowercased().contains(query) ?? false
339
+ let ocrText = includeOcr ? ocrResults[entry.wid]?.fullText : nil
340
+ let matchesOcrContent = ocrText?.lowercased().contains(query) ?? false
341
+
342
+ if matchesApp || matchesTitle || matchesSession || matchesOcrContent {
343
+ var obj = Encoders.window(entry)
344
+ if matchesOcrContent, let text = ocrText,
345
+ let range = text.lowercased().range(of: query) {
346
+ // Extract snippet around match
347
+ let half = max(0, (80 - text.distance(from: range.lowerBound, to: range.upperBound)) / 2)
348
+ let start = text.index(range.lowerBound, offsetBy: -half, limitedBy: text.startIndex) ?? text.startIndex
349
+ let end = text.index(range.upperBound, offsetBy: half, limitedBy: text.endIndex) ?? text.endIndex
350
+ var snippet = String(text[start..<end])
351
+ .replacingOccurrences(of: "\n", with: " ")
352
+ .trimmingCharacters(in: .whitespaces)
353
+ if start > text.startIndex { snippet = "…" + snippet }
354
+ if end < text.endIndex { snippet += "…" }
355
+ if case .object(var dict) = obj {
356
+ dict["ocrSnippet"] = .string(snippet)
357
+ dict["matchSource"] = .string("ocr")
358
+ obj = .object(dict)
359
+ }
360
+ } else if case .object(var dict) = obj {
361
+ let source = matchesTitle ? "title" : matchesApp ? "app" : "session"
362
+ dict["matchSource"] = .string(source)
363
+ obj = .object(dict)
364
+ }
365
+ matches.append(obj)
366
+ if matches.count >= limit { break }
367
+ }
368
+ }
369
+ return .array(matches)
370
+ }
371
+ ))
372
+
286
373
  api.register(Endpoint(
287
374
  method: "tmux.sessions",
288
375
  description: "List all tmux sessions with child process enrichment",
@@ -486,6 +573,85 @@ final class LatticesApi {
486
573
  }
487
574
  ))
488
575
 
576
+ // ── Endpoints: OCR ─────────────────────────────────────
577
+
578
+ api.register(Endpoint(
579
+ method: "ocr.snapshot",
580
+ description: "Get the latest OCR scan results for all on-screen windows",
581
+ access: .read,
582
+ params: [],
583
+ returns: .array(model: "OcrResult"),
584
+ handler: { _ in
585
+ let results = OcrModel.shared.results
586
+ return .array(results.values.map { Encoders.ocrResult($0) })
587
+ }
588
+ ))
589
+
590
+ api.register(Endpoint(
591
+ method: "ocr.search",
592
+ description: "Search OCR text across all windows (queries persistent SQLite FTS5 index by default)",
593
+ access: .read,
594
+ params: [
595
+ Param(name: "query", type: "string", required: true, description: "Search text (FTS5 query syntax)"),
596
+ Param(name: "app", type: "string", required: false, description: "Filter by app name"),
597
+ Param(name: "limit", type: "int", required: false, description: "Max results (default 50)"),
598
+ Param(name: "live", type: "bool", required: false, description: "Search in-memory snapshot instead of history (default false)"),
599
+ ],
600
+ returns: .array(model: "OcrSearchResult"),
601
+ handler: { params in
602
+ guard let query = params?["query"]?.stringValue else {
603
+ throw RouterError.missingParam("query")
604
+ }
605
+ let app = params?["app"]?.stringValue
606
+ let limit = params?["limit"]?.intValue ?? 50
607
+ let live = params?["live"]?.boolValue ?? false
608
+
609
+ if live {
610
+ // In-memory snapshot search (original behavior)
611
+ var results = Array(OcrModel.shared.results.values)
612
+ let q = query.lowercased()
613
+ results = results.filter { $0.fullText.lowercased().contains(q) }
614
+ if let app { results = results.filter { $0.app == app } }
615
+ return .array(results.prefix(limit).map { Encoders.ocrResult($0) })
616
+ }
617
+
618
+ // Persistent FTS5 search
619
+ let results = OcrStore.shared.search(query: query, app: app, limit: limit)
620
+ return .array(results.map { Encoders.ocrSearchResult($0) })
621
+ }
622
+ ))
623
+
624
+ api.register(Endpoint(
625
+ method: "ocr.history",
626
+ description: "Get OCR content timeline for a specific window",
627
+ access: .read,
628
+ params: [
629
+ Param(name: "wid", type: "uint32", required: true, description: "Window ID"),
630
+ Param(name: "limit", type: "int", required: false, description: "Max results (default 50)"),
631
+ ],
632
+ returns: .array(model: "OcrSearchResult"),
633
+ handler: { params in
634
+ guard let wid = params?["wid"]?.uint32Value else {
635
+ throw RouterError.missingParam("wid")
636
+ }
637
+ let limit = params?["limit"]?.intValue ?? 50
638
+ let results = OcrStore.shared.history(wid: wid, limit: limit)
639
+ return .array(results.map { Encoders.ocrSearchResult($0) })
640
+ }
641
+ ))
642
+
643
+ api.register(Endpoint(
644
+ method: "ocr.scan",
645
+ description: "Trigger an immediate OCR scan",
646
+ access: .mutate,
647
+ params: [],
648
+ returns: .ok,
649
+ handler: { _ in
650
+ OcrModel.shared.scan()
651
+ return .object(["ok": .bool(true)])
652
+ }
653
+ ))
654
+
489
655
  // ── Endpoints: Mutations ────────────────────────────────
490
656
 
491
657
  api.register(Endpoint(
@@ -857,6 +1023,50 @@ enum Encoders {
857
1023
  return .object(obj)
858
1024
  }
859
1025
 
1026
+ static func ocrResult(_ r: OcrWindowResult) -> JSON {
1027
+ .object([
1028
+ "wid": .int(Int(r.wid)),
1029
+ "app": .string(r.app),
1030
+ "title": .string(r.title),
1031
+ "frame": .object([
1032
+ "x": .double(r.frame.x),
1033
+ "y": .double(r.frame.y),
1034
+ "w": .double(r.frame.w),
1035
+ "h": .double(r.frame.h)
1036
+ ]),
1037
+ "fullText": .string(r.fullText),
1038
+ "blocks": .array(r.texts.map { block in
1039
+ .object([
1040
+ "text": .string(block.text),
1041
+ "confidence": .double(Double(block.confidence)),
1042
+ "x": .double(block.boundingBox.origin.x),
1043
+ "y": .double(block.boundingBox.origin.y),
1044
+ "w": .double(block.boundingBox.size.width),
1045
+ "h": .double(block.boundingBox.size.height)
1046
+ ])
1047
+ }),
1048
+ "timestamp": .double(r.timestamp.timeIntervalSince1970)
1049
+ ])
1050
+ }
1051
+
1052
+ static func ocrSearchResult(_ r: OcrSearchResult) -> JSON {
1053
+ .object([
1054
+ "id": .int(Int(r.id)),
1055
+ "wid": .int(Int(r.wid)),
1056
+ "app": .string(r.app),
1057
+ "title": .string(r.title),
1058
+ "frame": .object([
1059
+ "x": .double(r.frame.x),
1060
+ "y": .double(r.frame.y),
1061
+ "w": .double(r.frame.w),
1062
+ "h": .double(r.frame.h)
1063
+ ]),
1064
+ "fullText": .string(r.fullText),
1065
+ "snippet": .string(r.snippet),
1066
+ "timestamp": .double(r.timestamp.timeIntervalSince1970)
1067
+ ])
1068
+ }
1069
+
860
1070
  static func enrichedSession(_ s: TmuxSession) -> JSON {
861
1071
  let pm = ProcessModel.shared
862
1072
  return .object([
@@ -71,16 +71,7 @@ struct MainView: View {
71
71
  Spacer()
72
72
 
73
73
  headerButton(icon: "arrow.up.left.and.arrow.down.right") {
74
- // Dismiss the MenuBarExtra panel immediately
75
- for window in NSApp.windows {
76
- if window is NSPanel, window.isVisible,
77
- !CommandPaletteWindow.shared.isVisible || window.frame.width < 500 {
78
- // MenuBarExtra panels are small (~380px); command palette is 540px
79
- if window.frame.width <= 400 {
80
- window.orderOut(nil)
81
- }
82
- }
83
- }
74
+ (NSApp.delegate as? AppDelegate)?.dismissPopover()
84
75
  MainWindow.shared.show()
85
76
  }
86
77
  headerButton(icon: "arrow.clockwise") { scanner.scan(); inventory.refresh() }
@@ -190,8 +181,8 @@ struct MainView: View {
190
181
  .fill(Palette.border)
191
182
  .frame(height: 0.5)
192
183
 
193
- // Status bar
194
- statusBar
184
+ // Actions footer
185
+ actionsSection
195
186
  }
196
187
  }
197
188
 
@@ -253,96 +244,65 @@ struct MainView: View {
253
244
  }
254
245
  }
255
246
 
256
- // MARK: - Status bar
247
+ // MARK: - Actions footer
257
248
 
258
- private var statusBar: some View {
259
- HStack(spacing: 0) {
260
- // Settings button
261
- Button { SettingsWindowController.shared.show() } label: {
262
- Image(systemName: "gearshape")
263
- .font(.system(size: 10, weight: .medium))
264
- .foregroundColor(Palette.textMuted)
249
+ private var actionsSection: some View {
250
+ VStack(spacing: 0) {
251
+ ActionRow(shortcut: "1", label: "Command Palette", hotkey: hotkeyLabel(.palette), icon: "command", accentColor: Palette.running) {
252
+ CommandPaletteWindow.shared.toggle()
265
253
  }
266
- .buttonStyle(.plain)
267
- .help("Settings")
268
-
269
- // Diagnostics toggle
270
- Button { DiagnosticWindow.shared.toggle() } label: {
271
- HStack(spacing: 3) {
272
- Image(systemName: "stethoscope")
273
- .font(.system(size: 10, weight: .medium))
274
- .foregroundColor(DiagnosticWindow.shared.isVisible ? Palette.running : Palette.textMuted)
275
- if !permChecker.allGranted {
276
- Circle()
277
- .fill(Palette.detach)
278
- .frame(width: 5, height: 5)
279
- }
280
- }
254
+ ActionRow(shortcut: "2", label: "Screen Map", hotkey: hotkeyLabel(.screenMap), icon: "rectangle.3.group") {
255
+ ScreenMapWindowController.shared.toggle()
256
+ }
257
+ ActionRow(shortcut: "3", label: "Desktop Inventory", hotkey: hotkeyLabel(.desktopInventory), icon: "rectangle.split.2x1") {
258
+ CommandModeWindow.shared.toggle()
259
+ }
260
+ ActionRow(shortcut: "4", label: "Window Bezel", hotkey: hotkeyLabel(.bezel), icon: "macwindow") {
261
+ WindowBezel.showBezelForFrontmostWindow()
262
+ }
263
+ ActionRow(shortcut: "5", label: "Cheat Sheet", hotkey: hotkeyLabel(.cheatSheet), icon: "keyboard") {
264
+ CheatSheetHUD.shared.toggle()
265
+ }
266
+ ActionRow(shortcut: "6", label: "Omni Search", hotkey: hotkeyLabel(.omniSearch), icon: "magnifyingglass", accentColor: Palette.running) {
267
+ OmniSearchWindow.shared.toggle()
281
268
  }
282
- .buttonStyle(.plain)
283
- .help(!permChecker.allGranted ? "Permissions missing — open diagnostics" : "Toggle diagnostics")
284
269
 
285
270
  Rectangle()
286
271
  .fill(Palette.border)
287
- .frame(width: 0.5, height: 12)
288
- .padding(.horizontal, 8)
289
-
290
- // Config summary — keys dim, values white
291
- statusLine
292
-
293
- Spacer()
272
+ .frame(height: 0.5)
273
+ .padding(.horizontal, 10)
294
274
 
295
- // Palette hint
296
- Text("\u{2318}\u{21E7}M")
297
- .font(Typo.mono(9))
298
- .foregroundColor(Palette.textMuted)
299
- .help("Command palette (Cmd+Shift+M)")
275
+ ActionRow(shortcut: "S", label: "Settings", icon: "gearshape") {
276
+ SettingsWindowController.shared.show()
277
+ }
278
+ HStack(spacing: 0) {
279
+ ActionRow(shortcut: "D", label: "Diagnostics", icon: "stethoscope") {
280
+ DiagnosticWindow.shared.toggle()
281
+ }
282
+ if !permChecker.allGranted {
283
+ Circle()
284
+ .fill(Palette.detach)
285
+ .frame(width: 6, height: 6)
286
+ .padding(.trailing, 14)
287
+ }
288
+ }
300
289
 
301
290
  Rectangle()
302
291
  .fill(Palette.border)
303
- .frame(width: 0.5, height: 12)
304
- .padding(.horizontal, 6)
292
+ .frame(height: 0.5)
293
+ .padding(.horizontal, 10)
305
294
 
306
- // Quit
307
- Button { NSApp.terminate(nil) } label: {
308
- Image(systemName: "power")
309
- .font(.system(size: 9, weight: .medium))
310
- .foregroundColor(Palette.textMuted)
295
+ ActionRow(shortcut: "Q", label: "Quit", icon: "power", accentColor: Palette.kill) {
296
+ NSApp.terminate(nil)
311
297
  }
312
- .buttonStyle(.plain)
313
- .help("Quit lattices")
314
298
  }
315
- .padding(.horizontal, 14)
316
- .padding(.vertical, 7)
299
+ .padding(.vertical, 4)
317
300
  .background(Palette.surface.opacity(0.4))
318
301
  }
319
302
 
320
- private var statusLine: some View {
321
- HStack(spacing: 3) {
322
- statusPair("terminal", prefs.terminal.rawValue.lowercased())
323
- statusDot
324
- statusPair("mode", prefs.mode.rawValue)
325
- statusDot
326
- statusPair("home", "~/\((prefs.scanRoot as NSString).lastPathComponent)")
327
- }
328
- }
329
-
330
- private func statusPair(_ key: String, _ value: String) -> some View {
331
- HStack(spacing: 3) {
332
- Text(key + ":")
333
- .font(Typo.mono(9))
334
- .foregroundColor(Palette.textMuted)
335
- Text(value)
336
- .font(Typo.mono(9))
337
- .foregroundColor(Palette.text)
338
- }
339
- }
340
-
341
- private var statusDot: some View {
342
- Circle()
343
- .fill(Palette.textMuted)
344
- .frame(width: 2, height: 2)
345
- .padding(.horizontal, 4)
303
+ private func hotkeyLabel(_ action: HotkeyAction) -> String? {
304
+ guard let binding = HotkeyStore.shared.bindings[action] else { return nil }
305
+ return binding.displayParts.joined(separator: "")
346
306
  }
347
307
 
348
308
  // MARK: - Empty state
@@ -7,6 +7,7 @@ final class MainWindow {
7
7
  static let shared = MainWindow()
8
8
 
9
9
  private var window: NSWindow?
10
+ private var keyMonitor: Any?
10
11
 
11
12
  var isVisible: Bool { window?.isVisible ?? false }
12
13
 
@@ -61,10 +62,22 @@ final class MainWindow {
61
62
 
62
63
  window = w
63
64
  AppDelegate.updateActivationPolicy()
65
+
66
+ // Escape key → close
67
+ keyMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in
68
+ guard event.keyCode == 53,
69
+ self?.window?.isKeyWindow == true else { return event }
70
+ self?.close()
71
+ return nil
72
+ }
64
73
  }
65
74
 
66
75
  func close() {
67
76
  window?.orderOut(nil)
77
+ if let monitor = keyMonitor {
78
+ NSEvent.removeMonitor(monitor)
79
+ keyMonitor = nil
80
+ }
68
81
  AppDelegate.updateActivationPolicy()
69
82
  }
70
83
  }