@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
package/README.md CHANGED
@@ -1,39 +1,39 @@
1
1
  <picture>
2
- <img alt="lattice" src="www/public/og.png" />
2
+ <img alt="lattices" src="site/public/og.png" />
3
3
  </picture>
4
4
 
5
- # lattice
5
+ # lattices
6
6
 
7
7
  Declarative tmux sessions for developers.
8
8
 
9
- One command to create a named tmux session with your tools running. Auto-detects your stack, fully configurable with `.lattice.json`, and a native macOS menu bar app.
9
+ One command to create a named tmux session with your tools running. Auto-detects your stack, fully configurable with `.lattices.json`, and a native macOS menu bar app.
10
10
 
11
11
  ## Install
12
12
 
13
13
  ```sh
14
- npm install -g lattice
14
+ npm install -g @arach/lattices
15
15
  ```
16
16
 
17
17
  ## Quick start
18
18
 
19
19
  ```sh
20
20
  cd my-project
21
- lattice
21
+ lattices
22
22
  ```
23
23
 
24
- That's it. lattice creates a tmux session named after your project with Claude Code on the left and your dev server on the right. It detects your package manager and dev command automatically.
24
+ That's it. lattices creates a tmux session named after your project with Claude Code on the left and your dev server on the right. It detects your package manager and dev command automatically.
25
25
 
26
26
  ## How it works
27
27
 
28
- 1. **Run `lattice`** in any project directory
28
+ 1. **Run `lattices`** in any project directory
29
29
  2. A named tmux session is created with configured panes
30
30
  3. Commands start running in each pane immediately
31
- 4. Detach with `Ctrl+b d`, reattach by running `lattice` again
31
+ 4. Detach with `Ctrl+b d`, reattach by running `lattices` again
32
32
  5. Sessions persist in the background until you kill them
33
33
 
34
34
  ## Configuration
35
35
 
36
- Drop a `.lattice.json` in your project root:
36
+ Drop a `.lattices.json` in your project root:
37
37
 
38
38
  ```json
39
39
  {
@@ -75,7 +75,7 @@ Drop a `.lattice.json` in your project root:
75
75
 
76
76
  ## Auto-detection
77
77
 
78
- Without a config file, lattice reads your `package.json` and picks the right command:
78
+ Without a config file, lattices reads your `package.json` and picks the right command:
79
79
 
80
80
  - Checks `scripts.dev`, `scripts.start`, `scripts.serve`, `scripts.watch`
81
81
  - Detects package manager from lock files (pnpm, bun, yarn, npm)
@@ -86,9 +86,9 @@ Without a config file, lattice reads your `package.json` and picks the right com
86
86
  A macOS companion app for managing sessions without touching the terminal.
87
87
 
88
88
  ```sh
89
- lattice app # Launch (builds from source or downloads binary)
90
- lattice app build # Force rebuild from source
91
- lattice app quit # Stop the menu bar app
89
+ lattices app # Launch (builds from source or downloads binary)
90
+ lattices app build # Force rebuild from source
91
+ lattices app quit # Stop the menu bar app
92
92
  ```
93
93
 
94
94
  Features:
@@ -103,7 +103,7 @@ The app tries to compile from source first (requires Xcode CLI tools), falling b
103
103
  ## Tab groups
104
104
 
105
105
  Bundle related projects as tabs within a single terminal window.
106
- Configure in `~/.lattice/workspace.json`:
106
+ Configure in `~/.lattices/workspace.json`:
107
107
 
108
108
  ```json
109
109
  {
@@ -122,28 +122,28 @@ Configure in `~/.lattice/workspace.json`:
122
122
  }
123
123
  ```
124
124
 
125
- Each tab gets its own tmux window with pane layout from its `.lattice.json`.
125
+ Each tab gets its own tmux window with pane layout from its `.lattices.json`.
126
126
 
127
127
  ```sh
128
- lattice groups # List groups with status
129
- lattice group talkie # Launch or attach
130
- lattice tab talkie iOS # Switch to a tab
128
+ lattices groups # List groups with status
129
+ lattices group talkie # Launch or attach
130
+ lattices tab talkie iOS # Switch to a tab
131
131
  ```
132
132
 
133
- Groups can also be referenced in [workspace layers](https://lattice.dev/docs/layers) to tile a whole group into a screen position.
133
+ Groups can also be referenced in [workspace layers](https://lattices.dev/docs/layers) to tile a whole group into a screen position.
134
134
 
135
135
  ## CLI reference
136
136
 
137
137
  ```
138
- lattice Create or reattach to a session for the current project
139
- lattice init Generate a .lattice.json config
140
- lattice ls List active tmux sessions
141
- lattice kill [name] Kill a session (defaults to current project)
142
- lattice group [id] List tab groups or launch/attach a group
143
- lattice groups List all tab groups with status
144
- lattice tab <group> [tab] Switch tab within a group (by label or index)
145
- lattice app Launch the menu bar companion app
146
- lattice help Show help
138
+ lattices Create or reattach to a session for the current project
139
+ lattices init Generate a .lattices.json config
140
+ lattices ls List active tmux sessions
141
+ lattices kill [name] Kill a session (defaults to current project)
142
+ lattices group [id] List tab groups or launch/attach a group
143
+ lattices groups List all tab groups with status
144
+ lattices tab <group> [tab] Switch tab within a group (by label or index)
145
+ lattices app Launch the menu bar companion app
146
+ lattices help Show help
147
147
  ```
148
148
 
149
149
  ## Requirements
@@ -0,0 +1,61 @@
1
+ import SwiftUI
2
+
3
+ /// A single action row with shortcut badge, label, optional icon, and hotkey hint.
4
+ struct ActionRow: View {
5
+ let shortcut: String
6
+ let label: String
7
+ var hotkey: String? = nil
8
+ var icon: String? = nil
9
+ var accentColor: Color = Palette.textDim
10
+ var action: () -> Void
11
+
12
+ @State private var isHovered = false
13
+
14
+ var body: some View {
15
+ Button(action: action) {
16
+ HStack(spacing: 10) {
17
+ // Shortcut badge
18
+ Text(shortcut)
19
+ .font(Typo.monoBold(10))
20
+ .foregroundColor(accentColor)
21
+ .frame(width: 18, height: 18)
22
+ .background(
23
+ RoundedRectangle(cornerRadius: 4)
24
+ .fill(accentColor.opacity(0.12))
25
+ )
26
+
27
+ // Icon
28
+ if let icon {
29
+ Image(systemName: icon)
30
+ .font(.system(size: 11, weight: .medium))
31
+ .foregroundColor(isHovered ? Palette.text : Palette.textDim)
32
+ .frame(width: 14)
33
+ }
34
+
35
+ // Label
36
+ Text(label)
37
+ .font(Typo.mono(12))
38
+ .foregroundColor(isHovered ? Palette.text : Palette.textDim)
39
+ .lineLimit(1)
40
+
41
+ Spacer()
42
+
43
+ // Hotkey
44
+ if let hotkey {
45
+ Text(hotkey)
46
+ .font(Typo.mono(10))
47
+ .foregroundColor(Palette.textMuted)
48
+ }
49
+ }
50
+ .padding(.horizontal, 10)
51
+ .padding(.vertical, 6)
52
+ .background(
53
+ RoundedRectangle(cornerRadius: 5)
54
+ .fill(isHovered ? Palette.surfaceHov : Color.clear)
55
+ )
56
+ .contentShape(Rectangle())
57
+ }
58
+ .buttonStyle(.plain)
59
+ .onHover { isHovered = $0 }
60
+ }
61
+ }
@@ -3,47 +3,8 @@ import SwiftUI
3
3
  @main
4
4
  struct LatticesApp: App {
5
5
  @NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
6
- @StateObject private var scanner = ProjectScanner.shared
7
-
8
- /// 3×3 grid icon for the menu bar — L-shape bright, rest dim (template for auto light/dark)
9
- private static let menuBarIcon: NSImage = {
10
- let size: CGFloat = 18
11
- let img = NSImage(size: NSSize(width: size, height: size), flipped: false) { _ in
12
- let pad: CGFloat = 2
13
- let gap: CGFloat = 1.5
14
- let cellSize = (size - 2 * pad - 2 * gap) / 3
15
-
16
- // L-shape: left column + bottom row are solid, rest are dim
17
- let solidCells: Set<Int> = [0, 3, 6, 7, 8] // (0,0),(1,0),(2,0),(2,1),(2,2)
18
-
19
- for row in 0..<3 {
20
- for col in 0..<3 {
21
- let idx = row * 3 + col
22
- let x = pad + CGFloat(col) * (cellSize + gap)
23
- let y = pad + CGFloat(row) * (cellSize + gap)
24
- let rect = NSRect(x: x, y: y, width: cellSize, height: cellSize)
25
-
26
- if solidCells.contains(idx) {
27
- NSColor.black.setFill()
28
- } else {
29
- NSColor.black.withAlphaComponent(0.25).setFill()
30
- }
31
- let path = NSBezierPath(roundedRect: rect, xRadius: 0.8, yRadius: 0.8)
32
- path.fill()
33
- }
34
- }
35
- return true
36
- }
37
- img.isTemplate = true
38
- return img
39
- }()
40
6
 
41
7
  var body: some Scene {
42
- MenuBarExtra {
43
- MainView(scanner: scanner)
44
- } label: {
45
- Image(nsImage: Self.menuBarIcon)
46
- }
47
- .menuBarExtraStyle(.window)
8
+ Settings { EmptyView() }
48
9
  }
49
10
  }
@@ -1,18 +1,55 @@
1
1
  import AppKit
2
+ import SwiftUI
2
3
 
3
- /// Registers the global hotkey (Cmd+Shift+D) on launch.
4
- /// The menu bar itself is handled by SwiftUI's MenuBarExtra.
5
- class AppDelegate: NSObject, NSApplicationDelegate {
4
+ /// Manages the NSStatusItem (menu bar icon), left-click popover, and right-click context menu.
5
+ /// Replaces the previous SwiftUI MenuBarExtra approach for full click-event control.
6
+ class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
7
+
8
+ private var statusItem: NSStatusItem!
9
+ private var popover: NSPopover?
10
+ private var contextMenu: NSMenu!
11
+
12
+ /// 3×3 grid icon for the menu bar — L-shape bright, rest dim (template for auto light/dark)
13
+ private static let menuBarIcon: NSImage = {
14
+ let size: CGFloat = 18
15
+ let img = NSImage(size: NSSize(width: size, height: size), flipped: true) { _ in
16
+ let pad: CGFloat = 2
17
+ let gap: CGFloat = 1.5
18
+ let cellSize = (size - 2 * pad - 2 * gap) / 3
19
+
20
+ let solidCells: Set<Int> = [0, 3, 6, 7, 8]
21
+
22
+ for row in 0..<3 {
23
+ for col in 0..<3 {
24
+ let idx = row * 3 + col
25
+ let x = pad + CGFloat(col) * (cellSize + gap)
26
+ let y = pad + CGFloat(row) * (cellSize + gap)
27
+ let rect = NSRect(x: x, y: y, width: cellSize, height: cellSize)
28
+
29
+ if solidCells.contains(idx) {
30
+ NSColor.black.setFill()
31
+ } else {
32
+ NSColor.black.withAlphaComponent(0.25).setFill()
33
+ }
34
+ let path = NSBezierPath(roundedRect: rect, xRadius: 0.8, yRadius: 0.8)
35
+ path.fill()
36
+ }
37
+ }
38
+ return true
39
+ }
40
+ img.isTemplate = true
41
+ return img
42
+ }()
6
43
 
7
44
  /// Toggle between .accessory (hidden from Dock/Cmd+Tab) and .regular (visible)
8
45
  /// based on whether any managed windows are open.
9
- /// Call this after showing or dismissing a window.
10
46
  static func updateActivationPolicy() {
11
47
  let hasVisibleWindow =
12
48
  CommandModeWindow.shared.isVisible ||
13
49
  CommandPaletteWindow.shared.isVisible ||
14
50
  MainWindow.shared.isVisible ||
15
- ScreenMapWindowController.shared.isVisible
51
+ ScreenMapWindowController.shared.isVisible ||
52
+ OmniSearchWindow.shared.isVisible
16
53
  let desired: NSApplication.ActivationPolicy = hasVisibleWindow ? .regular : .accessory
17
54
  if NSApp.activationPolicy() != desired {
18
55
  NSApp.setActivationPolicy(desired)
@@ -26,14 +63,29 @@ class AppDelegate: NSObject, NSApplicationDelegate {
26
63
  NSApp.setActivationPolicy(.accessory)
27
64
  NSApp.appearance = NSAppearance(named: .darkAqua)
28
65
 
29
- CommandPaletteWindow.shared.configure(scanner: ProjectScanner.shared)
66
+ // --- Status item ---
67
+ statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.squareLength)
68
+ if let button = statusItem.button {
69
+ button.image = Self.menuBarIcon
70
+ button.action = #selector(statusItemClicked(_:))
71
+ button.sendAction(on: [.leftMouseUp, .rightMouseUp])
72
+ button.target = self
73
+ }
74
+
75
+ // --- Context menu (right-click) ---
76
+ contextMenu = buildContextMenu()
77
+
78
+ // --- Hotkey registration ---
79
+ let scanner = ProjectScanner.shared
80
+ CommandPaletteWindow.shared.configure(scanner: scanner)
30
81
 
31
- // Register all hotkeys via HotkeyStore (user-configurable bindings)
32
82
  let store = HotkeyStore.shared
33
83
  store.register(action: .palette) { CommandPaletteWindow.shared.toggle() }
34
84
  store.register(action: .screenMap) { ScreenMapWindowController.shared.toggle() }
35
85
  store.register(action: .bezel) { WindowBezel.showBezelForFrontmostWindow() }
36
86
  store.register(action: .cheatSheet) { CheatSheetHUD.shared.toggle() }
87
+ store.register(action: .desktopInventory) { CommandModeWindow.shared.toggle() }
88
+ store.register(action: .omniSearch) { OmniSearchWindow.shared.toggle() }
37
89
 
38
90
  // Layer-switching hotkeys
39
91
  let workspace = WorkspaceManager.shared
@@ -58,21 +110,13 @@ class AppDelegate: NSObject, NSApplicationDelegate {
58
110
  }
59
111
  store.register(action: .tileDistribute) { WindowTiler.distributeVisible() }
60
112
 
61
- // Style the MenuBarExtra panel when it appears
62
- NotificationCenter.default.addObserver(
63
- forName: NSWindow.didBecomeKeyNotification,
64
- object: nil,
65
- queue: .main
66
- ) { note in
67
- guard let panel = note.object as? NSPanel else { return }
68
- Self.stylePanel(panel)
69
- }
70
-
71
113
  // Check macOS permissions (Accessibility, Screen Recording)
72
114
  PermissionChecker.shared.check()
73
115
 
74
116
  // Start daemon services
117
+ OcrStore.shared.open()
75
118
  DesktopModel.shared.start()
119
+ OcrModel.shared.start()
76
120
  TmuxModel.shared.start()
77
121
  ProcessModel.shared.start()
78
122
  LatticesApi.setup()
@@ -93,12 +137,98 @@ class AppDelegate: NSObject, NSApplicationDelegate {
93
137
  }
94
138
  }
95
139
 
96
- private static func stylePanel(_ panel: NSPanel) {
97
- let bg = NSColor(red: 0.11, green: 0.11, blue: 0.12, alpha: 1.0)
98
- panel.backgroundColor = bg
99
- panel.isOpaque = false
100
- panel.hasShadow = true
101
- panel.becomesKeyOnlyIfNeeded = false
102
- panel.invalidateShadow()
140
+ // MARK: - Status item click handler
141
+
142
+ @objc private func statusItemClicked(_ sender: Any?) {
143
+ guard let event = NSApp.currentEvent, let button = statusItem.button else { return }
144
+
145
+ if event.type == .rightMouseUp {
146
+ // Right-click → context menu
147
+ contextMenu.popUp(positioning: nil, at: NSPoint(x: 0, y: button.bounds.height + 4), in: button)
148
+ } else {
149
+ // Left-click → toggle popover
150
+ if let shown = popover, shown.isShown {
151
+ shown.performClose(sender)
152
+ } else {
153
+ let p = makePopover()
154
+ p.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
155
+ p.contentViewController?.view.window?.makeKey()
156
+ }
157
+ }
158
+ }
159
+
160
+ /// Dismiss the popover programmatically (e.g. from the pop-out button).
161
+ func dismissPopover() {
162
+ popover?.performClose(nil)
103
163
  }
164
+
165
+ /// Create a fresh popover each time so the SwiftUI view tree isn't kept alive
166
+ /// when the popover is closed — prevents continuous CPU usage from @Published updates.
167
+ private func makePopover() -> NSPopover {
168
+ let p = NSPopover()
169
+ p.contentViewController = NSHostingController(rootView: MainView(scanner: ProjectScanner.shared))
170
+ p.behavior = .transient
171
+ p.contentSize = NSSize(width: 380, height: 520)
172
+ p.appearance = NSAppearance(named: .darkAqua)
173
+ p.delegate = self
174
+ popover = p
175
+ return p
176
+ }
177
+
178
+ func popoverDidClose(_ notification: Notification) {
179
+ // Tear down the SwiftUI view tree so observed models stop driving re-renders
180
+ popover?.contentViewController = nil
181
+ popover = nil
182
+ }
183
+
184
+ // MARK: - Context menu
185
+
186
+ private func buildContextMenu() -> NSMenu {
187
+ let menu = NSMenu()
188
+
189
+ let actions: [(String, String, Selector)] = [
190
+ ("Command Palette", "⌘⇧M", #selector(menuCommandPalette)),
191
+ ("Screen Map", "", #selector(menuScreenMap)),
192
+ ("Desktop Inventory", "", #selector(menuDesktopInventory)),
193
+ ("Window Bezel", "", #selector(menuWindowBezel)),
194
+ ("Cheat Sheet", "", #selector(menuCheatSheet)),
195
+ ("Omni Search", "", #selector(menuOmniSearch)),
196
+ ]
197
+ for (title, shortcut, action) in actions {
198
+ let item = NSMenuItem(title: title, action: action, keyEquivalent: "")
199
+ item.target = self
200
+ if !shortcut.isEmpty {
201
+ // Display-only; the actual hotkey is global
202
+ }
203
+ menu.addItem(item)
204
+ }
205
+
206
+ menu.addItem(.separator())
207
+
208
+ let settings = NSMenuItem(title: "Settings…", action: #selector(menuSettings), keyEquivalent: ",")
209
+ settings.target = self
210
+ menu.addItem(settings)
211
+
212
+ let diag = NSMenuItem(title: "Diagnostics", action: #selector(menuDiagnostics), keyEquivalent: "")
213
+ diag.target = self
214
+ menu.addItem(diag)
215
+
216
+ menu.addItem(.separator())
217
+
218
+ let quit = NSMenuItem(title: "Quit Lattices", action: #selector(menuQuit), keyEquivalent: "q")
219
+ quit.target = self
220
+ menu.addItem(quit)
221
+
222
+ return menu
223
+ }
224
+
225
+ @objc private func menuCommandPalette() { CommandPaletteWindow.shared.toggle() }
226
+ @objc private func menuScreenMap() { ScreenMapWindowController.shared.toggle() }
227
+ @objc private func menuDesktopInventory() { CommandModeWindow.shared.toggle() }
228
+ @objc private func menuWindowBezel() { WindowBezel.showBezelForFrontmostWindow() }
229
+ @objc private func menuCheatSheet() { CheatSheetHUD.shared.toggle() }
230
+ @objc private func menuOmniSearch() { OmniSearchWindow.shared.toggle() }
231
+ @objc private func menuSettings() { SettingsWindowController.shared.show() }
232
+ @objc private func menuDiagnostics() { DiagnosticWindow.shared.toggle() }
233
+ @objc private func menuQuit() { NSApp.terminate(nil) }
104
234
  }
@@ -231,6 +231,7 @@ struct CheatSheetView: View {
231
231
  shortcutRow(action: .screenMap)
232
232
  shortcutRow(action: .bezel)
233
233
  shortcutRow(action: .cheatSheet)
234
+ shortcutRow(action: .desktopInventory)
234
235
  }
235
236
  }
236
237
 
@@ -261,7 +261,9 @@ final class CommandModeState: ObservableObject {
261
261
  let matchesApp = win.appName?.lowercased().contains(query) ?? false
262
262
  let matchesTitle = win.title.lowercased().contains(query)
263
263
  let matchesLattices = win.latticesSession?.lowercased().contains(query) ?? false
264
- if !matchesApp && !matchesTitle && !matchesLattices { return false }
264
+ let matchesOcr = OcrModel.shared.results[win.id]?.fullText
265
+ .lowercased().contains(query) ?? false
266
+ if !matchesApp && !matchesTitle && !matchesLattices && !matchesOcr { return false }
265
267
  }
266
268
 
267
269
  return true
@@ -305,6 +307,37 @@ final class CommandModeState: ObservableObject {
305
307
  filteredSnapshot?.allWindows ?? []
306
308
  }
307
309
 
310
+ var ocrMatchSnippets: [UInt32: String] {
311
+ guard isSearching, !searchQuery.isEmpty else { return [:] }
312
+ let query = searchQuery.lowercased()
313
+ let ocrResults = OcrModel.shared.results
314
+ var snippets: [UInt32: String] = [:]
315
+ for win in flatWindowList {
316
+ // Only show snippet if match came from OCR, not title/app
317
+ let matchesApp = win.appName?.lowercased().contains(query) ?? false
318
+ let matchesTitle = win.title.lowercased().contains(query)
319
+ let matchesLattices = win.latticesSession?.lowercased().contains(query) ?? false
320
+ if matchesApp || matchesTitle || matchesLattices { continue }
321
+ if let ocr = ocrResults[win.id],
322
+ let range = ocr.fullText.lowercased().range(of: query) {
323
+ snippets[win.id] = Self.extractSnippet(from: ocr.fullText, around: range)
324
+ }
325
+ }
326
+ return snippets
327
+ }
328
+
329
+ private static func extractSnippet(from text: String, around range: Range<String.Index>, maxLen: Int = 80) -> String {
330
+ let half = max(0, (maxLen - text.distance(from: range.lowerBound, to: range.upperBound)) / 2)
331
+ let start = text.index(range.lowerBound, offsetBy: -half, limitedBy: text.startIndex) ?? text.startIndex
332
+ let end = text.index(range.upperBound, offsetBy: half, limitedBy: text.endIndex) ?? text.endIndex
333
+ var s = String(text[start..<end])
334
+ .replacingOccurrences(of: "\n", with: " ")
335
+ .trimmingCharacters(in: .whitespaces)
336
+ if start > text.startIndex { s = "…" + s }
337
+ if end < text.endIndex { s += "…" }
338
+ return s
339
+ }
340
+
308
341
  func enter() {
309
342
  inventory = buildInventory()
310
343
  chords = buildChords()
@@ -404,20 +437,8 @@ final class CommandModeState: ObservableObject {
404
437
  }
405
438
 
406
439
  switch keyCode {
407
- case 53: // Escape
408
- if isSearching {
409
- deactivateSearch()
410
- return true
411
- }
412
- if !selectedWindowIds.isEmpty {
413
- clearSelection()
414
- return true
415
- }
416
- // No selection — back to chord view
417
- desktopMode = .browsing
418
- activePreset = nil
419
- phase = .inventory
420
- onPanelResize?(chordPanelSize.0, chordPanelSize.1)
440
+ case 53: // Escape — always dismiss
441
+ onDismiss?()
421
442
  return true
422
443
 
423
444
  case 126: // ↑
@@ -560,8 +581,8 @@ final class CommandModeState: ObservableObject {
560
581
 
561
582
  private func handleTilingKey(_ keyCode: UInt16, modifiers: NSEvent.ModifierFlags = []) -> Bool {
562
583
  switch keyCode {
563
- case 53: // Escape back to browsing
564
- desktopMode = .browsing
584
+ case 53: // Escape always dismiss
585
+ onDismiss?()
565
586
  return true
566
587
 
567
588
  case 123: tileSelectedWindow(to: .left); return true // ←
@@ -593,8 +614,8 @@ final class CommandModeState: ObservableObject {
593
614
 
594
615
  private func handleGridPreviewKey(_ keyCode: UInt16) -> Bool {
595
616
  switch keyCode {
596
- case 53: // Escape back to browsing
597
- desktopMode = .browsing
617
+ case 53: // Escape always dismiss
618
+ onDismiss?()
598
619
  return true
599
620
 
600
621
  case 36, 1: // Enter or s → apply the layout
@@ -282,13 +282,15 @@ struct CommandModeView: View {
282
282
  Image(systemName: "magnifyingglass")
283
283
  .font(.system(size: 11))
284
284
  .foregroundColor(Palette.textDim)
285
- TextField("Search windows...", text: $state.searchQuery)
285
+ TextField("Search windows & content…", text: $state.searchQuery)
286
286
  .textFieldStyle(.plain)
287
287
  .font(Typo.mono(12))
288
288
  .foregroundColor(Palette.text)
289
289
  .focused($isSearchFieldFocused)
290
290
  if !state.searchQuery.isEmpty {
291
- Text("\(state.flatWindowList.count) matches")
291
+ let total = state.flatWindowList.count
292
+ let ocrCount = state.ocrMatchSnippets.count
293
+ Text(ocrCount > 0 ? "\(total) matches (\(ocrCount) by content)" : "\(total) matches")
292
294
  .font(Typo.mono(9))
293
295
  .foregroundColor(Palette.textMuted)
294
296
  }
@@ -442,6 +444,7 @@ struct CommandModeView: View {
442
444
  VStack(alignment: .leading, spacing: 0) {
443
445
  if appGroup.windows.count == 1, let win = appGroup.windows.first {
444
446
  inventoryRow(window: win, appLabel: appGroup.appName)
447
+ ocrSnippetRow(for: win.id)
445
448
  if state.isSelected(win.id), let path = win.inventoryPath {
446
449
  inventoryPathLabel(path)
447
450
  }
@@ -454,6 +457,7 @@ struct CommandModeView: View {
454
457
  .padding(.bottom, 1)
455
458
  ForEach(appGroup.windows) { win in
456
459
  inventoryRow(window: win, indented: true)
460
+ ocrSnippetRow(for: win.id)
457
461
  if state.isSelected(win.id), let path = win.inventoryPath {
458
462
  inventoryPathLabel(path)
459
463
  }
@@ -471,6 +475,24 @@ struct CommandModeView: View {
471
475
  .padding(.vertical, 2)
472
476
  }
473
477
 
478
+ @ViewBuilder
479
+ private func ocrSnippetRow(for windowId: UInt32) -> some View {
480
+ if let snippet = state.ocrMatchSnippets[windowId] {
481
+ HStack(spacing: 4) {
482
+ Image(systemName: "text.magnifyingglass")
483
+ .font(.system(size: 7))
484
+ .foregroundColor(Palette.textMuted)
485
+ Text(snippet)
486
+ .font(Typo.mono(9).italic())
487
+ .foregroundColor(Palette.textMuted)
488
+ .lineLimit(1)
489
+ .truncationMode(.tail)
490
+ }
491
+ .padding(.horizontal, 28)
492
+ .padding(.vertical, 1)
493
+ }
494
+ }
495
+
474
496
  /// Unified inventory row — handles both single-app rows (with appLabel) and
475
497
  /// sub-rows under a multi-window app header (with indented).
476
498
  private func inventoryRow(
@@ -1278,6 +1300,9 @@ struct CommandModeView: View {
1278
1300
  private func installKeyHandler() {
1279
1301
  eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { event in
1280
1302
  guard state.phase == .inventory || state.phase == .desktopInventory else { return event }
1303
+ // Only handle keys when our panel is the key window
1304
+ guard let panel = CommandModeWindow.shared.panelWindow,
1305
+ panel.isKeyWindow else { return event }
1281
1306
  let consumed = state.handleKey(event.keyCode, modifiers: event.modifierFlags)
1282
1307
  return consumed ? nil : event
1283
1308
  }
@@ -386,6 +386,14 @@ final class DaemonServer: ObservableObject {
386
386
  "pids": .array(interesting.map { .int($0) })
387
387
  ])
388
388
  )
389
+ case .ocrScanComplete(let windowCount, let totalBlocks):
390
+ daemonEvent = DaemonEvent(
391
+ event: "ocr.scanComplete",
392
+ data: .object([
393
+ "windowCount": .int(windowCount),
394
+ "totalBlocks": .int(totalBlocks)
395
+ ])
396
+ )
389
397
  }
390
398
  broadcast(daemonEvent)
391
399
  }