@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.
- package/README.md +85 -9
- package/app/Info.plist +30 -0
- package/app/Lattices.app/Contents/Info.plist +8 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Lattices.app/Contents/Resources/AppIcon.icns +0 -0
- package/app/Lattices.app/Contents/Resources/tap.wav +0 -0
- package/app/Lattices.app/Contents/_CodeSignature/CodeResources +139 -0
- package/app/Lattices.entitlements +15 -0
- package/app/Package.swift +8 -1
- package/app/Resources/tap.wav +0 -0
- package/app/Sources/AdvisorLearningStore.swift +90 -0
- package/app/Sources/AgentSession.swift +377 -0
- package/app/Sources/AppDelegate.swift +45 -12
- package/app/Sources/AppShellView.swift +81 -8
- package/app/Sources/AudioProvider.swift +386 -0
- package/app/Sources/CheatSheetHUD.swift +261 -19
- package/app/Sources/DaemonProtocol.swift +13 -0
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DesktopModel.swift +189 -6
- package/app/Sources/DesktopModelTypes.swift +2 -0
- package/app/Sources/DiagnosticLog.swift +104 -2
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HUDBottomBar.swift +279 -0
- package/app/Sources/HUDController.swift +1158 -0
- package/app/Sources/HUDLeftBar.swift +849 -0
- package/app/Sources/HUDMinimap.swift +179 -0
- package/app/Sources/HUDRightBar.swift +774 -0
- package/app/Sources/HUDState.swift +367 -0
- package/app/Sources/HUDTopBar.swift +243 -0
- package/app/Sources/HandsOffSession.swift +802 -0
- package/app/Sources/HomeDashboardView.swift +125 -0
- package/app/Sources/HotkeyManager.swift +2 -0
- package/app/Sources/HotkeyStore.swift +49 -9
- package/app/Sources/IntentEngine.swift +962 -0
- package/app/Sources/Intents/CreateLayerIntent.swift +54 -0
- package/app/Sources/Intents/DistributeIntent.swift +56 -0
- package/app/Sources/Intents/FocusIntent.swift +69 -0
- package/app/Sources/Intents/HelpIntent.swift +41 -0
- package/app/Sources/Intents/KillIntent.swift +47 -0
- package/app/Sources/Intents/LatticeIntent.swift +78 -0
- package/app/Sources/Intents/LaunchIntent.swift +67 -0
- package/app/Sources/Intents/ListSessionsIntent.swift +32 -0
- package/app/Sources/Intents/ListWindowsIntent.swift +30 -0
- package/app/Sources/Intents/ScanIntent.swift +52 -0
- package/app/Sources/Intents/SearchIntent.swift +190 -0
- package/app/Sources/Intents/SwitchLayerIntent.swift +50 -0
- package/app/Sources/Intents/TileIntent.swift +61 -0
- package/app/Sources/LatticesApi.swift +1275 -30
- package/app/Sources/LauncherHUD.swift +348 -0
- package/app/Sources/MainView.swift +147 -44
- package/app/Sources/MouseFinder.swift +222 -0
- package/app/Sources/OcrModel.swift +34 -1
- package/app/Sources/OmniSearchState.swift +99 -102
- package/app/Sources/OnboardingView.swift +457 -0
- package/app/Sources/PermissionChecker.swift +2 -12
- package/app/Sources/PiChatDock.swift +454 -0
- package/app/Sources/PiChatSession.swift +815 -0
- package/app/Sources/PiWorkspaceView.swift +364 -0
- package/app/Sources/PlacementSpec.swift +195 -0
- package/app/Sources/Preferences.swift +59 -0
- package/app/Sources/ProjectScanner.swift +58 -45
- package/app/Sources/ScreenMapState.swift +701 -55
- package/app/Sources/ScreenMapView.swift +843 -103
- package/app/Sources/ScreenMapWindowController.swift +22 -0
- package/app/Sources/SessionLayerStore.swift +285 -0
- package/app/Sources/SessionManager.swift +4 -1
- package/app/Sources/SettingsView.swift +186 -3
- package/app/Sources/Theme.swift +9 -8
- package/app/Sources/TmuxModel.swift +7 -0
- package/app/Sources/TmuxQuery.swift +27 -3
- package/app/Sources/VoiceChatView.swift +192 -0
- package/app/Sources/VoiceCommandWindow.swift +1594 -0
- package/app/Sources/VoiceIntentResolver.swift +671 -0
- package/app/Sources/VoxClient.swift +454 -0
- package/app/Sources/WindowTiler.swift +348 -87
- package/app/Sources/WorkspaceManager.swift +127 -18
- package/app/Tests/StageDragTests.swift +333 -0
- package/app/Tests/StageJoinTests.swift +313 -0
- package/app/Tests/StageManagerTests.swift +280 -0
- package/app/Tests/StageTileTests.swift +353 -0
- package/assets/AppIcon.icns +0 -0
- package/bin/client.ts +16 -0
- package/bin/{daemon-client.js → daemon-client.ts} +49 -30
- package/bin/handsoff-infer.ts +280 -0
- package/bin/handsoff-worker.ts +740 -0
- package/bin/lattices-app.ts +338 -0
- package/bin/lattices-dev +208 -0
- package/bin/{lattices.js → lattices.ts} +777 -140
- package/bin/project-twin.ts +645 -0
- package/docs/agent-execution-plan.md +562 -0
- package/docs/agent-layer-guide.md +207 -0
- package/docs/agents.md +142 -0
- package/docs/api.md +153 -34
- package/docs/app.md +29 -1
- package/docs/config.md +5 -1
- package/docs/handsoff-test-scenarios.md +84 -0
- package/docs/layers.md +20 -20
- package/docs/ocr.md +14 -5
- package/docs/overview.md +5 -1
- package/docs/presentation-execution-review.md +491 -0
- package/docs/prompts/hands-off-system.md +374 -0
- package/docs/prompts/hands-off-turn.md +30 -0
- package/docs/prompts/voice-advisor.md +31 -0
- package/docs/prompts/voice-fallback.md +23 -0
- package/docs/tiling-reference.md +167 -0
- package/docs/twins.md +138 -0
- package/docs/voice-command-protocol.md +278 -0
- package/docs/voice.md +219 -0
- package/package.json +29 -11
- package/bin/client.js +0 -4
- 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
|
-
//
|
|
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
|
-
.
|
|
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
|
|
78
|
-
self
|
|
80
|
+
self.results = []
|
|
81
|
+
self.refreshSummary()
|
|
82
|
+
} else if q.count == 1 {
|
|
83
|
+
self.quickSearch(q)
|
|
79
84
|
} else {
|
|
80
|
-
self
|
|
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
|
-
|
|
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
|
-
//
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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:
|
|
102
|
-
title:
|
|
103
|
-
subtitle:
|
|
104
|
-
icon:
|
|
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
|
-
|
|
114
|
-
|
|
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: -
|
|
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
|
|
215
|
-
let
|
|
216
|
-
|
|
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
|
|