@arach/lattices 0.1.0 → 0.2.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 +101 -90
- package/app/Sources/ActionRow.swift +61 -0
- package/app/Sources/App.swift +1 -40
- package/app/Sources/AppDelegate.swift +154 -24
- package/app/Sources/CheatSheetHUD.swift +1 -0
- package/app/Sources/CommandModeState.swift +40 -19
- package/app/Sources/CommandModeView.swift +27 -2
- package/app/Sources/DaemonServer.swift +8 -0
- package/app/Sources/DiagnosticLog.swift +19 -1
- package/app/Sources/EventBus.swift +1 -0
- package/app/Sources/HotkeyManager.swift +1 -0
- package/app/Sources/HotkeyStore.swift +9 -1
- package/app/Sources/LatticesApi.swift +210 -0
- package/app/Sources/MainView.swift +46 -86
- package/app/Sources/MainWindow.swift +13 -0
- package/app/Sources/OcrModel.swift +309 -0
- package/app/Sources/OcrStore.swift +295 -0
- package/app/Sources/OmniSearchState.swift +283 -0
- package/app/Sources/OmniSearchView.swift +288 -0
- package/app/Sources/OmniSearchWindow.swift +105 -0
- package/app/Sources/PaletteCommand.swift +11 -1
- package/app/Sources/PermissionChecker.swift +12 -2
- package/app/Sources/Preferences.swift +44 -0
- package/app/Sources/ScreenMapState.swift +7 -17
- package/app/Sources/ScreenMapView.swift +3 -0
- package/app/Sources/SettingsView.swift +534 -122
- package/app/Sources/Theme.swift +39 -0
- package/app/Sources/WindowTiler.swift +59 -56
- package/bin/lattices-app.js +23 -7
- package/bin/lattices.js +123 -0
- package/docs/api.md +390 -249
- package/docs/app.md +75 -28
- package/docs/concepts.md +45 -136
- package/docs/config.md +8 -7
- package/docs/layers.md +16 -18
- package/docs/ocr.md +185 -0
- package/docs/overview.md +39 -34
- package/docs/quickstart.md +34 -35
- package/package.json +6 -2
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
564
|
-
|
|
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
|
|
597
|
-
|
|
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
|
|
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
|
-
|
|
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
|
}
|
|
@@ -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
|
-
|
|
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")
|
|
@@ -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([
|