@lattices/cli 0.3.0 → 0.4.1

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 (111) hide show
  1. package/README.md +85 -9
  2. package/app/Info.plist +30 -0
  3. package/app/Lattices.app/Contents/Info.plist +8 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
  6. package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
  7. package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
  8. package/app/Lattices.entitlements +15 -0
  9. package/app/Package.swift +8 -1
  10. package/app/Resources/tap.wav +0 -0
  11. package/app/Sources/AdvisorLearningStore.swift +90 -0
  12. package/app/Sources/AgentSession.swift +377 -0
  13. package/app/Sources/AppDelegate.swift +45 -12
  14. package/app/Sources/AppShellView.swift +81 -8
  15. package/app/Sources/AudioProvider.swift +386 -0
  16. package/app/Sources/CheatSheetHUD.swift +261 -19
  17. package/app/Sources/DaemonProtocol.swift +13 -0
  18. package/app/Sources/DaemonServer.swift +8 -0
  19. package/app/Sources/DesktopModel.swift +189 -6
  20. package/app/Sources/DesktopModelTypes.swift +2 -0
  21. package/app/Sources/DiagnosticLog.swift +104 -2
  22. package/app/Sources/EventBus.swift +1 -0
  23. package/app/Sources/HUDBottomBar.swift +279 -0
  24. package/app/Sources/HUDController.swift +1158 -0
  25. package/app/Sources/HUDLeftBar.swift +849 -0
  26. package/app/Sources/HUDMinimap.swift +179 -0
  27. package/app/Sources/HUDRightBar.swift +774 -0
  28. package/app/Sources/HUDState.swift +367 -0
  29. package/app/Sources/HUDTopBar.swift +243 -0
  30. package/app/Sources/HandsOffSession.swift +802 -0
  31. package/app/Sources/HomeDashboardView.swift +125 -0
  32. package/app/Sources/HotkeyManager.swift +2 -0
  33. package/app/Sources/HotkeyStore.swift +49 -9
  34. package/app/Sources/IntentEngine.swift +962 -0
  35. package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
  36. package/app/Sources/Intents/DistributeIntent.swift +56 -0
  37. package/app/Sources/Intents/FocusIntent.swift +69 -0
  38. package/app/Sources/Intents/HelpIntent.swift +41 -0
  39. package/app/Sources/Intents/KillIntent.swift +47 -0
  40. package/app/Sources/Intents/LatticeIntent.swift +78 -0
  41. package/app/Sources/Intents/LaunchIntent.swift +67 -0
  42. package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
  43. package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
  44. package/app/Sources/Intents/ScanIntent.swift +52 -0
  45. package/app/Sources/Intents/SearchIntent.swift +190 -0
  46. package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
  47. package/app/Sources/Intents/TileIntent.swift +61 -0
  48. package/app/Sources/LatticesApi.swift +1275 -30
  49. package/app/Sources/LauncherHUD.swift +348 -0
  50. package/app/Sources/MainView.swift +147 -44
  51. package/app/Sources/MouseFinder.swift +222 -0
  52. package/app/Sources/OcrModel.swift +34 -1
  53. package/app/Sources/OmniSearchState.swift +99 -102
  54. package/app/Sources/OnboardingView.swift +457 -0
  55. package/app/Sources/PermissionChecker.swift +2 -12
  56. package/app/Sources/PiChatDock.swift +454 -0
  57. package/app/Sources/PiChatSession.swift +815 -0
  58. package/app/Sources/PiWorkspaceView.swift +364 -0
  59. package/app/Sources/PlacementSpec.swift +195 -0
  60. package/app/Sources/Preferences.swift +59 -0
  61. package/app/Sources/ProjectScanner.swift +58 -45
  62. package/app/Sources/ScreenMapState.swift +701 -55
  63. package/app/Sources/ScreenMapView.swift +843 -103
  64. package/app/Sources/ScreenMapWindowController.swift +22 -0
  65. package/app/Sources/SessionLayerStore.swift +285 -0
  66. package/app/Sources/SessionManager.swift +4 -1
  67. package/app/Sources/SettingsView.swift +186 -3
  68. package/app/Sources/Theme.swift +9 -8
  69. package/app/Sources/TmuxModel.swift +7 -0
  70. package/app/Sources/TmuxQuery.swift +27 -3
  71. package/app/Sources/VoiceChatView.swift +192 -0
  72. package/app/Sources/VoiceCommandWindow.swift +1594 -0
  73. package/app/Sources/VoiceIntentResolver.swift +671 -0
  74. package/app/Sources/VoxClient.swift +454 -0
  75. package/app/Sources/WindowTiler.swift +348 -87
  76. package/app/Sources/WorkspaceManager.swift +127 -18
  77. package/app/Tests/StageDragTests.swift +333 -0
  78. package/app/Tests/StageJoinTests.swift +313 -0
  79. package/app/Tests/StageManagerTests.swift +280 -0
  80. package/app/Tests/StageTileTests.swift +353 -0
  81. package/assets/AppIcon.icns +0 -0
  82. package/bin/client.ts +16 -0
  83. package/bin/{daemon-client.js → daemon-client.ts} +49 -30
  84. package/bin/handsoff-infer.ts +280 -0
  85. package/bin/handsoff-worker.ts +740 -0
  86. package/bin/lattices-app.ts +338 -0
  87. package/bin/lattices-dev +208 -0
  88. package/bin/{lattices.js → lattices.ts} +777 -140
  89. package/bin/project-twin.ts +645 -0
  90. package/docs/agent-execution-plan.md +562 -0
  91. package/docs/agent-layer-guide.md +207 -0
  92. package/docs/agents.md +142 -0
  93. package/docs/api.md +153 -34
  94. package/docs/app.md +29 -1
  95. package/docs/config.md +5 -1
  96. package/docs/handsoff-test-scenarios.md +84 -0
  97. package/docs/layers.md +20 -20
  98. package/docs/ocr.md +14 -5
  99. package/docs/overview.md +5 -1
  100. package/docs/presentation-execution-review.md +491 -0
  101. package/docs/prompts/hands-off-system.md +374 -0
  102. package/docs/prompts/hands-off-turn.md +30 -0
  103. package/docs/prompts/voice-advisor.md +31 -0
  104. package/docs/prompts/voice-fallback.md +23 -0
  105. package/docs/tiling-reference.md +167 -0
  106. package/docs/twins.md +138 -0
  107. package/docs/voice-command-protocol.md +278 -0
  108. package/docs/voice.md +219 -0
  109. package/package.json +29 -11
  110. package/bin/client.js +0 -4
  111. package/bin/lattices-app.js +0 -221
@@ -0,0 +1,222 @@
1
+ import AppKit
2
+ import CoreGraphics
3
+
4
+ /// Locates the mouse cursor with an animated sonar pulse overlay.
5
+ /// "Find" shows rings at the current cursor position.
6
+ /// "Summon" warps the cursor to screen center (or a given point).
7
+ final class MouseFinder {
8
+ static let shared = MouseFinder()
9
+
10
+ private var overlayWindows: [NSWindow] = []
11
+ private var dismissTimer: Timer?
12
+ private var animationTimer: Timer?
13
+ private var animationStart: CFTimeInterval = 0
14
+ private let animationDuration: CFTimeInterval = 1.5
15
+
16
+ // MARK: - Find (highlight current position)
17
+
18
+ func find() {
19
+ let pos = NSEvent.mouseLocation
20
+ showSonar(at: pos)
21
+ }
22
+
23
+ // MARK: - Summon (warp to center of the screen the mouse is on, or a specific point)
24
+
25
+ func summon(to point: CGPoint? = nil) {
26
+ let target: NSPoint
27
+ if let point {
28
+ target = point
29
+ } else {
30
+ let screen = mouseScreen()
31
+ let frame = screen.frame
32
+ target = NSPoint(x: frame.midX, y: frame.midY)
33
+ }
34
+
35
+ // CGWarpMouseCursorPosition uses top-left origin
36
+ let primaryHeight = NSScreen.screens.first?.frame.height ?? 0
37
+ let cgPoint = CGPoint(x: target.x, y: primaryHeight - target.y)
38
+ CGWarpMouseCursorPosition(cgPoint)
39
+ // Re-associate mouse with cursor position after warp
40
+ CGAssociateMouseAndMouseCursorPosition(1)
41
+
42
+ showSonar(at: target)
43
+ }
44
+
45
+ // MARK: - Sonar Animation
46
+
47
+ private func showSonar(at nsPoint: NSPoint) {
48
+ dismiss()
49
+
50
+ let screens = NSScreen.screens
51
+ guard !screens.isEmpty else { return }
52
+
53
+ let ringCount = 3
54
+ let maxRadius: CGFloat = 120
55
+ let totalSize = maxRadius * 2 + 20
56
+
57
+ for screen in screens {
58
+ // Only show on screens near the cursor
59
+ let extendedBounds = screen.frame.insetBy(dx: -maxRadius, dy: -maxRadius)
60
+ guard extendedBounds.contains(nsPoint) else { continue }
61
+
62
+ let windowFrame = NSRect(
63
+ x: nsPoint.x - totalSize / 2,
64
+ y: nsPoint.y - totalSize / 2,
65
+ width: totalSize,
66
+ height: totalSize
67
+ )
68
+
69
+ let window = NSWindow(
70
+ contentRect: windowFrame,
71
+ styleMask: .borderless,
72
+ backing: .buffered,
73
+ defer: false
74
+ )
75
+ window.isOpaque = false
76
+ window.backgroundColor = .clear
77
+ window.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.maximumWindow)))
78
+ window.hasShadow = false
79
+ window.ignoresMouseEvents = true
80
+ window.collectionBehavior = [.canJoinAllSpaces, .stationary]
81
+
82
+ let sonarView = SonarView(
83
+ frame: NSRect(origin: .zero, size: windowFrame.size),
84
+ ringCount: ringCount,
85
+ maxRadius: maxRadius
86
+ )
87
+ window.contentView = sonarView
88
+
89
+ window.alphaValue = 0
90
+ window.orderFrontRegardless()
91
+ overlayWindows.append(window)
92
+
93
+ // Fade in
94
+ NSAnimationContext.runAnimationGroup { ctx in
95
+ ctx.duration = 0.1
96
+ window.animator().alphaValue = 1.0
97
+ }
98
+ }
99
+
100
+ // Animate the rings expanding using CACurrentMediaTime for state
101
+ animationStart = CACurrentMediaTime()
102
+ let interval = 1.0 / 60.0
103
+
104
+ animationTimer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] timer in
105
+ guard let self else { timer.invalidate(); return }
106
+ let elapsed = CACurrentMediaTime() - self.animationStart
107
+ let progress = CGFloat(min(elapsed / self.animationDuration, 1.0))
108
+
109
+ for window in self.overlayWindows {
110
+ (window.contentView as? SonarView)?.progress = progress
111
+ window.contentView?.needsDisplay = true
112
+ }
113
+
114
+ if progress >= 1.0 {
115
+ timer.invalidate()
116
+ self.animationTimer = nil
117
+ }
118
+ }
119
+
120
+ // Auto-dismiss after animation + hold
121
+ dismissTimer = Timer.scheduledTimer(withTimeInterval: 2.5, repeats: false) { [weak self] _ in
122
+ self?.fadeOut()
123
+ }
124
+ }
125
+
126
+ private func fadeOut() {
127
+ let windows = overlayWindows
128
+ NSAnimationContext.runAnimationGroup({ ctx in
129
+ ctx.duration = 0.4
130
+ for window in windows {
131
+ window.animator().alphaValue = 0
132
+ }
133
+ }, completionHandler: { [weak self] in
134
+ self?.dismiss()
135
+ })
136
+ }
137
+
138
+ func dismiss() {
139
+ animationTimer?.invalidate()
140
+ animationTimer = nil
141
+ dismissTimer?.invalidate()
142
+ dismissTimer = nil
143
+ for window in overlayWindows {
144
+ window.orderOut(nil)
145
+ }
146
+ overlayWindows.removeAll()
147
+ }
148
+
149
+ private func mouseScreen() -> NSScreen {
150
+ let pos = NSEvent.mouseLocation
151
+ return NSScreen.screens.first(where: { $0.frame.contains(pos) }) ?? NSScreen.screens[0]
152
+ }
153
+ }
154
+
155
+ // MARK: - Sonar Ring View
156
+
157
+ private class SonarView: NSView {
158
+ let ringCount: Int
159
+ let maxRadius: CGFloat
160
+ var progress: CGFloat = 0
161
+
162
+ init(frame: NSRect, ringCount: Int, maxRadius: CGFloat) {
163
+ self.ringCount = ringCount
164
+ self.maxRadius = maxRadius
165
+ super.init(frame: frame)
166
+ }
167
+
168
+ required init?(coder: NSCoder) { fatalError() }
169
+
170
+ override func draw(_ dirtyRect: NSRect) {
171
+ guard let ctx = NSGraphicsContext.current?.cgContext else { return }
172
+
173
+ let center = CGPoint(x: bounds.midX, y: bounds.midY)
174
+
175
+ // Draw rings from outermost to innermost
176
+ for i in 0..<ringCount {
177
+ let ringDelay = CGFloat(i) * 0.15
178
+ let denom = 1.0 - ringDelay * CGFloat(ringCount - 1) / CGFloat(ringCount)
179
+ let ringProgress = max(0, min(1, (progress - ringDelay) / denom))
180
+
181
+ guard ringProgress > 0 else { continue }
182
+
183
+ // Ease out cubic
184
+ let eased = 1.0 - pow(1.0 - ringProgress, 3)
185
+
186
+ let radius = maxRadius * eased
187
+ let alpha = (1.0 - eased) * 0.8
188
+
189
+ // Ring stroke
190
+ ctx.setStrokeColor(NSColor(calibratedRed: 0.4, green: 0.7, blue: 1.0, alpha: alpha).cgColor)
191
+ ctx.setLineWidth(2.5 - CGFloat(i) * 0.5)
192
+ ctx.addEllipse(in: CGRect(
193
+ x: center.x - radius,
194
+ y: center.y - radius,
195
+ width: radius * 2,
196
+ height: radius * 2
197
+ ))
198
+ ctx.strokePath()
199
+ }
200
+
201
+ // Center dot — stays visible
202
+ let dotRadius: CGFloat = 6
203
+ let dotAlpha = max(0.3, 1.0 - progress * 0.5)
204
+ ctx.setFillColor(NSColor(calibratedRed: 0.4, green: 0.7, blue: 1.0, alpha: dotAlpha).cgColor)
205
+ ctx.fillEllipse(in: CGRect(
206
+ x: center.x - dotRadius,
207
+ y: center.y - dotRadius,
208
+ width: dotRadius * 2,
209
+ height: dotRadius * 2
210
+ ))
211
+
212
+ // Outer glow on center dot
213
+ ctx.setFillColor(NSColor(calibratedRed: 0.4, green: 0.7, blue: 1.0, alpha: dotAlpha * 0.2).cgColor)
214
+ let glowRadius: CGFloat = 12
215
+ ctx.fillEllipse(in: CGRect(
216
+ x: center.x - glowRadius,
217
+ y: center.y - glowRadius,
218
+ width: glowRadius * 2,
219
+ height: glowRadius * 2
220
+ ))
221
+ }
222
+ }
@@ -60,8 +60,11 @@ final class OcrModel: ObservableObject {
60
60
  return
61
61
  }
62
62
  let deepInterval = prefs.ocrDeepInterval
63
- // Defer initial scan — let the first timer tick handle it (grace period on launch)
64
63
  DiagnosticLog.shared.info("OcrModel: starting (quick=\(self.interval)s/\(prefs.ocrQuickLimit)win, deep=\(deepInterval)s/\(prefs.ocrDeepLimit)win)")
64
+ // Run initial scan immediately so search works right away
65
+ DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 2) { [weak self] in
66
+ self?.quickScan()
67
+ }
65
68
  timer = Timer.scheduledTimer(withTimeInterval: self.interval, repeats: true) { [weak self] _ in
66
69
  guard let self, self.enabled else { return }
67
70
  self.quickScan()
@@ -90,6 +93,36 @@ final class OcrModel: ObservableObject {
90
93
  }
91
94
  }
92
95
 
96
+ // MARK: - Single Window Scan
97
+
98
+ /// Scan a single window by wid (AX extraction, instant).
99
+ func scanSingle(wid: UInt32) {
100
+ guard let entry = DesktopModel.shared.windows[wid] else { return }
101
+ queue.async { [weak self] in
102
+ guard let self else { return }
103
+ if let axResult = self.axExtractor.extract(pid: entry.pid, wid: wid) {
104
+ let blocks = axResult.texts.map { text in
105
+ OcrTextBlock(text: text, confidence: 1.0, boundingBox: .zero)
106
+ }
107
+ let result = OcrWindowResult(
108
+ wid: wid,
109
+ app: entry.app,
110
+ title: entry.title,
111
+ frame: entry.frame,
112
+ texts: blocks,
113
+ fullText: axResult.fullText,
114
+ timestamp: Date(),
115
+ source: .accessibility
116
+ )
117
+ OcrStore.shared.insert(results: [result])
118
+ DispatchQueue.main.async {
119
+ self.results[wid] = result
120
+ DiagnosticLog.shared.info("OcrModel: single scan wid=\(wid) → \(axResult.texts.count) blocks")
121
+ }
122
+ }
123
+ }
124
+ }
125
+
93
126
  // MARK: - Scan
94
127
 
95
128
  /// Quick scan: AX-only text extraction for topmost windows (called every 60s).
@@ -69,39 +69,110 @@ final class OmniSearchState: ObservableObject {
69
69
  private var debounceTimer: AnyCancellable?
70
70
 
71
71
  init() {
72
- // Debounce search by 150ms
72
+ // Single-char queries fire immediately with a lightweight search;
73
+ // longer queries debounce 150ms and run the full search.
73
74
  debounceTimer = $query
74
- .debounce(for: .milliseconds(150), scheduler: RunLoop.main)
75
+ .removeDuplicates()
75
76
  .sink { [weak self] q in
77
+ guard let self else { return }
78
+ self.fullSearchTask?.cancel()
76
79
  if q.isEmpty {
77
- self?.results = []
78
- self?.refreshSummary()
80
+ self.results = []
81
+ self.refreshSummary()
82
+ } else if q.count == 1 {
83
+ self.quickSearch(q)
79
84
  } else {
80
- self?.search(q)
85
+ self.fullSearchTask = DispatchWorkItem { [weak self] in
86
+ self?.search(q)
87
+ }
88
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: self.fullSearchTask!)
81
89
  }
82
90
  }
83
91
 
84
92
  refreshSummary()
85
93
  }
86
94
 
87
- // MARK: - Search
95
+ private var fullSearchTask: DispatchWorkItem?
96
+
97
+ // MARK: - Quick search (first character — window index only, no terminal/OCR/projects)
98
+
99
+ private func quickSearch(_ query: String) {
100
+ let q = query.lowercased()
101
+ let desktop = DesktopModel.shared
102
+ var all: [OmniResult] = []
103
+
104
+ for entry in desktop.allWindows() {
105
+ var score = 0
106
+ if entry.title.lowercased().contains(q) { score += 3 }
107
+ if entry.app.lowercased().contains(q) { score += 2 }
108
+ if entry.latticesSession?.lowercased().contains(q) == true { score += 3 }
109
+ guard score > 0 else { continue }
110
+
111
+ let wid = entry.wid
112
+ let pid = entry.pid
113
+ all.append(OmniResult(
114
+ kind: .window,
115
+ title: entry.app,
116
+ subtitle: entry.title.isEmpty ? "Window \(wid)" : entry.title,
117
+ icon: "macwindow",
118
+ score: score
119
+ ) {
120
+ WindowTiler.focusWindow(wid: wid, pid: pid)
121
+ })
122
+ }
123
+
124
+ all.sort { $0.score > $1.score }
125
+ results = Array(all.prefix(12))
126
+ selectedIndex = 0
127
+ }
128
+
129
+ // MARK: - Search (delegates to unified lattices.search API)
88
130
 
89
131
  private func search(_ query: String) {
90
132
  let q = query.lowercased()
91
133
  var all: [OmniResult] = []
92
134
 
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
135
+ // ── Daemon search: windows, terminals, OCR — single source of truth ──
136
+ // This is synchronous on the daemon's in-process API, not a network call.
137
+ if let json = try? LatticesApi.shared.dispatch(
138
+ method: "lattices.search",
139
+ params: .object(["query": .string(q)])
140
+ ), case .array(let hits) = json {
141
+ let desktop = DesktopModel.shared
142
+ for hit in hits {
143
+ guard let wid = hit["wid"]?.uint32Value else { continue }
144
+ let app = hit["app"]?.stringValue ?? ""
145
+ let title = hit["title"]?.stringValue ?? ""
146
+ let score = hit["score"]?.intValue ?? 0
147
+ let pid = desktop.windows[wid]?.pid ?? 0
148
+ let sources = (hit["matchSources"]?.arrayValue ?? []).compactMap(\.stringValue)
149
+
150
+ // Determine kind from match sources
151
+ let hasOcr = sources.contains("ocr")
152
+ let hasTerminal = !Set(sources).isDisjoint(with: ["cwd", "tab", "tmux", "process"])
153
+ let kind: OmniResultKind = hasOcr ? .ocrContent : hasTerminal ? .session : .window
154
+
155
+ let icon: String
156
+ let subtitle: String
157
+ switch kind {
158
+ case .ocrContent:
159
+ icon = "doc.text.magnifyingglass"
160
+ subtitle = hit["ocrSnippet"]?.stringValue ?? title
161
+ case .session:
162
+ icon = "terminal"
163
+ let tabs = hit["terminalTabs"]?.arrayValue ?? []
164
+ let cwds = tabs.compactMap { $0["cwd"]?.stringValue }
165
+ subtitle = cwds.first ?? title
166
+ default:
167
+ icon = "macwindow"
168
+ subtitle = title.isEmpty ? "Window \(wid)" : title
169
+ }
170
+
100
171
  all.append(OmniResult(
101
- kind: .window,
102
- title: win.app,
103
- subtitle: win.title.isEmpty ? "Window \(win.wid)" : win.title,
104
- icon: "macwindow",
172
+ kind: kind,
173
+ title: app,
174
+ subtitle: subtitle,
175
+ icon: icon,
105
176
  score: score
106
177
  ) {
107
178
  WindowTiler.focusWindow(wid: wid, pid: pid)
@@ -109,10 +180,9 @@ final class OmniSearchState: ObservableObject {
109
180
  }
110
181
  }
111
182
 
112
- // Projects
113
- let scanner = ProjectScanner.shared
114
- for project in scanner.projects {
115
- let score = scoreMatch(q, against: [project.name, project.path])
183
+ // ── Projects: local-only (not window-centric, so not in daemon search) ──
184
+ for project in ProjectScanner.shared.projects {
185
+ let score = scoreProjectMatch(q, name: project.name, path: project.path)
116
186
  if score > 0 {
117
187
  let proj = project
118
188
  all.append(OmniResult(
@@ -127,93 +197,20 @@ final class OmniSearchState: ObservableObject {
127
197
  }
128
198
  }
129
199
 
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
200
  all.sort { $0.score > $1.score }
192
-
193
201
  results = all
194
202
  selectedIndex = 0
195
203
  }
196
204
 
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
- }
205
+ // MARK: - Project scoring (local — projects aren't windows)
213
206
 
214
- private func scoreOcr(_ query: String, fullText: String) -> Int {
215
- let lower = fullText.lowercased()
216
- if lower.contains(query) { return 40 }
207
+ private func scoreProjectMatch(_ query: String, name: String, path: String) -> Int {
208
+ let lowerName = name.lowercased()
209
+ let lowerPath = path.lowercased()
210
+ if lowerName == query { return 100 }
211
+ if lowerName.hasPrefix(query) { return 80 }
212
+ if lowerName.contains(query) { return 60 }
213
+ if lowerPath.contains(query) { return 40 }
217
214
  return 0
218
215
  }
219
216