@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
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
import ApplicationServices
|
|
1
3
|
import Foundation
|
|
2
4
|
|
|
3
5
|
// MARK: - Registry Types
|
|
@@ -371,6 +373,257 @@ final class LatticesApi {
|
|
|
371
373
|
}
|
|
372
374
|
))
|
|
373
375
|
|
|
376
|
+
// MARK: - Unified Search
|
|
377
|
+
|
|
378
|
+
api.register(Endpoint(
|
|
379
|
+
method: "lattices.search",
|
|
380
|
+
description: "Unified search across windows, terminals, and OCR. Single entry point for all search surfaces.",
|
|
381
|
+
access: .read,
|
|
382
|
+
params: [
|
|
383
|
+
Param(name: "query", type: "string", required: true, description: "Search text"),
|
|
384
|
+
Param(name: "sources", type: "array<string>", required: false, description: "Data sources to include: titles, apps, sessions, cwd, tabs, tmux, ocr, processes. Omit for smart default (everything except ocr). Use ['all'] for everything."),
|
|
385
|
+
Param(name: "after", type: "string", required: false, description: "ISO8601 timestamp — only windows interacted with after this time"),
|
|
386
|
+
Param(name: "before", type: "string", required: false, description: "ISO8601 timestamp — only windows interacted with before this time"),
|
|
387
|
+
Param(name: "recency", type: "bool", required: false, description: "Boost score for recently-focused windows (default true)"),
|
|
388
|
+
Param(name: "limit", type: "int", required: false, description: "Max results (default 20)"),
|
|
389
|
+
// Legacy compat
|
|
390
|
+
Param(name: "mode", type: "string", required: false, description: "Legacy: 'quick', 'complete', 'terminal'. Mapped to sources internally."),
|
|
391
|
+
],
|
|
392
|
+
returns: .array(model: "SearchResult"),
|
|
393
|
+
handler: { params in
|
|
394
|
+
guard let query = params?["query"]?.stringValue?.lowercased(), !query.isEmpty else {
|
|
395
|
+
throw RouterError.missingParam("query")
|
|
396
|
+
}
|
|
397
|
+
let limit = params?["limit"]?.intValue ?? 20
|
|
398
|
+
let useRecency = params?["recency"]?.boolValue ?? true
|
|
399
|
+
|
|
400
|
+
// ── Resolve sources ──
|
|
401
|
+
|
|
402
|
+
// All available source names
|
|
403
|
+
let allSources: Set<String> = ["titles", "apps", "sessions", "cwd", "tabs", "tmux", "ocr", "processes"]
|
|
404
|
+
// Smart default: everything except OCR (fast)
|
|
405
|
+
let defaultSources: Set<String> = ["titles", "apps", "sessions", "cwd", "tabs", "tmux"]
|
|
406
|
+
|
|
407
|
+
var sources: Set<String>
|
|
408
|
+
if let arr = params?["sources"]?.arrayValue {
|
|
409
|
+
let names = arr.compactMap(\.stringValue)
|
|
410
|
+
if names.contains("all") {
|
|
411
|
+
sources = allSources
|
|
412
|
+
} else if names.contains("terminals") {
|
|
413
|
+
// Shorthand expansion
|
|
414
|
+
sources = Set(names).subtracting(["terminals"]).union(["cwd", "tabs", "tmux", "processes"])
|
|
415
|
+
} else {
|
|
416
|
+
sources = Set(names).intersection(allSources)
|
|
417
|
+
if sources.isEmpty { sources = defaultSources }
|
|
418
|
+
}
|
|
419
|
+
} else if let mode = params?["mode"]?.stringValue {
|
|
420
|
+
// Legacy mode param → sources mapping
|
|
421
|
+
switch mode {
|
|
422
|
+
case "quick": sources = ["titles", "apps", "sessions"]
|
|
423
|
+
case "terminal": sources = ["cwd", "tabs", "tmux", "processes"]
|
|
424
|
+
default: sources = allSources // "complete"
|
|
425
|
+
}
|
|
426
|
+
} else {
|
|
427
|
+
sources = defaultSources
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
let includeWindowIndex = !sources.isDisjoint(with: ["titles", "apps", "sessions"])
|
|
431
|
+
let includeOcr = sources.contains("ocr")
|
|
432
|
+
let includeTerminals = !sources.isDisjoint(with: ["cwd", "tabs", "tmux", "processes"])
|
|
433
|
+
|
|
434
|
+
// ── Resolve time filters ──
|
|
435
|
+
|
|
436
|
+
let isoFormatter = ISO8601DateFormatter()
|
|
437
|
+
isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
438
|
+
let isoFormatterNoFrac = ISO8601DateFormatter()
|
|
439
|
+
|
|
440
|
+
func parseDate(_ str: String?) -> Date? {
|
|
441
|
+
guard let str else { return nil }
|
|
442
|
+
return isoFormatter.date(from: str) ?? isoFormatterNoFrac.date(from: str)
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
let afterDate = parseDate(params?["after"]?.stringValue)
|
|
446
|
+
let beforeDate = parseDate(params?["before"]?.stringValue)
|
|
447
|
+
|
|
448
|
+
// Default time window: 2 days (only applies when no explicit time filters given)
|
|
449
|
+
let defaultCutoff = (afterDate == nil && beforeDate == nil)
|
|
450
|
+
? Date().addingTimeInterval(-2 * 24 * 3600)
|
|
451
|
+
: nil
|
|
452
|
+
|
|
453
|
+
let now = Date()
|
|
454
|
+
let desktop = DesktopModel.shared
|
|
455
|
+
|
|
456
|
+
/// Check if a window passes the time filter.
|
|
457
|
+
/// Windows with no interaction date are included if they're currently on screen (live windows).
|
|
458
|
+
func passesTimeFilter(wid: UInt32, entry: WindowEntry) -> Bool {
|
|
459
|
+
if let interacted = desktop.lastInteractionDate(for: wid) {
|
|
460
|
+
if let after = afterDate, interacted < after { return false }
|
|
461
|
+
if let before = beforeDate, interacted > before { return false }
|
|
462
|
+
if let cutoff = defaultCutoff, interacted < cutoff { return false }
|
|
463
|
+
return true
|
|
464
|
+
}
|
|
465
|
+
// No interaction date — include if currently visible (it's a live window we just haven't tracked yet)
|
|
466
|
+
return entry.isOnScreen
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/// Recency boost: windows focused recently score higher.
|
|
470
|
+
/// Frontmost (zIndex 0) gets +4, last 5 min +3, last hour +2, last day +1.
|
|
471
|
+
func recencyBoost(wid: UInt32, zIndex: Int) -> Int {
|
|
472
|
+
guard useRecency else { return 0 }
|
|
473
|
+
if zIndex == 0 { return 4 }
|
|
474
|
+
guard let last = desktop.lastInteractionDate(for: wid) else { return 0 }
|
|
475
|
+
let ago = now.timeIntervalSince(last)
|
|
476
|
+
if ago < 300 { return 3 } // 5 min
|
|
477
|
+
if ago < 3600 { return 2 } // 1 hour
|
|
478
|
+
if ago < 86400 { return 1 } // 1 day
|
|
479
|
+
return 0
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ── Accumulator ──
|
|
483
|
+
|
|
484
|
+
struct Accum {
|
|
485
|
+
let entry: WindowEntry
|
|
486
|
+
var score: Int
|
|
487
|
+
var sources: [String]
|
|
488
|
+
var ocrSnippet: String?
|
|
489
|
+
var tabs: [JSON]
|
|
490
|
+
var lastInteraction: Date?
|
|
491
|
+
}
|
|
492
|
+
var byWid: [UInt32: Accum] = [:]
|
|
493
|
+
|
|
494
|
+
// ── Tier 1: Window index (title, app, session) ──
|
|
495
|
+
|
|
496
|
+
if includeWindowIndex {
|
|
497
|
+
let ocrResults = includeOcr ? OcrModel.shared.results : [:]
|
|
498
|
+
let checkTitles = sources.contains("titles")
|
|
499
|
+
let checkApps = sources.contains("apps")
|
|
500
|
+
let checkSessions = sources.contains("sessions")
|
|
501
|
+
|
|
502
|
+
for entry in desktop.allWindows() {
|
|
503
|
+
guard passesTimeFilter(wid: entry.wid, entry: entry) else { continue }
|
|
504
|
+
|
|
505
|
+
var score = 0
|
|
506
|
+
var matchSources: [String] = []
|
|
507
|
+
var ocrSnippet: String? = nil
|
|
508
|
+
|
|
509
|
+
if checkTitles && entry.title.lowercased().contains(query) { score += 3; matchSources.append("title") }
|
|
510
|
+
if checkApps && entry.app.lowercased().contains(query) { score += 2; matchSources.append("app") }
|
|
511
|
+
if checkSessions && entry.latticesSession?.lowercased().contains(query) == true { score += 3; matchSources.append("session") }
|
|
512
|
+
|
|
513
|
+
if includeOcr, let ocrResult = ocrResults[entry.wid] {
|
|
514
|
+
let text = ocrResult.fullText
|
|
515
|
+
if text.lowercased().contains(query) {
|
|
516
|
+
score += 1; matchSources.append("ocr")
|
|
517
|
+
if let range = text.lowercased().range(of: query) {
|
|
518
|
+
let half = max(0, (80 - text.distance(from: range.lowerBound, to: range.upperBound)) / 2)
|
|
519
|
+
let start = text.index(range.lowerBound, offsetBy: -half, limitedBy: text.startIndex) ?? text.startIndex
|
|
520
|
+
let end = text.index(range.upperBound, offsetBy: half, limitedBy: text.endIndex) ?? text.endIndex
|
|
521
|
+
var snippet = String(text[start..<end])
|
|
522
|
+
.replacingOccurrences(of: "\n", with: " ")
|
|
523
|
+
.trimmingCharacters(in: .whitespaces)
|
|
524
|
+
if start > text.startIndex { snippet = "…" + snippet }
|
|
525
|
+
if end < text.endIndex { snippet += "…" }
|
|
526
|
+
ocrSnippet = snippet
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
if score > 0 {
|
|
532
|
+
score += recencyBoost(wid: entry.wid, zIndex: entry.zIndex)
|
|
533
|
+
byWid[entry.wid] = Accum(
|
|
534
|
+
entry: entry, score: score, sources: matchSources,
|
|
535
|
+
ocrSnippet: ocrSnippet, tabs: [],
|
|
536
|
+
lastInteraction: desktop.lastInteractionDate(for: entry.wid)
|
|
537
|
+
)
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
// ── Tier 2: Terminal inspection (cwd, tab titles, tmux sessions, processes) ──
|
|
543
|
+
|
|
544
|
+
if includeTerminals {
|
|
545
|
+
let checkCwd = sources.contains("cwd")
|
|
546
|
+
let checkTabs = sources.contains("tabs")
|
|
547
|
+
let checkTmux = sources.contains("tmux")
|
|
548
|
+
let checkProcesses = sources.contains("processes")
|
|
549
|
+
|
|
550
|
+
let instances = ProcessModel.shared.synthesizeTerminals()
|
|
551
|
+
for inst in instances {
|
|
552
|
+
let cwdMatch = checkCwd && (inst.cwd?.lowercased().contains(query) ?? false)
|
|
553
|
+
let tabMatch = checkTabs && (inst.tabTitle?.lowercased().contains(query) ?? false)
|
|
554
|
+
let tmuxMatch = checkTmux && (inst.tmuxSession?.lowercased().contains(query) ?? false)
|
|
555
|
+
let processMatch = checkProcesses && inst.processes.contains {
|
|
556
|
+
$0.comm.lowercased().contains(query) || $0.args.lowercased().contains(query)
|
|
557
|
+
}
|
|
558
|
+
guard cwdMatch || tabMatch || tmuxMatch || processMatch else { continue }
|
|
559
|
+
|
|
560
|
+
var tab: [String: JSON] = [:]
|
|
561
|
+
if let idx = inst.tabIndex { tab["tabIndex"] = .int(idx) }
|
|
562
|
+
if let cwd = inst.cwd { tab["cwd"] = .string(cwd) }
|
|
563
|
+
if let title = inst.tabTitle { tab["tabTitle"] = .string(title) }
|
|
564
|
+
tab["hasClaude"] = .bool(inst.hasClaude)
|
|
565
|
+
if let session = inst.tmuxSession { tab["tmuxSession"] = .string(session) }
|
|
566
|
+
if processMatch {
|
|
567
|
+
let matched = inst.processes.filter {
|
|
568
|
+
$0.comm.lowercased().contains(query) || $0.args.lowercased().contains(query)
|
|
569
|
+
}
|
|
570
|
+
tab["matchedProcesses"] = .array(matched.map { .string($0.comm) })
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
let tabJson = JSON.object(tab)
|
|
574
|
+
var tabScore = 0
|
|
575
|
+
if cwdMatch { tabScore += 3 }
|
|
576
|
+
if tabMatch { tabScore += 2 }
|
|
577
|
+
if tmuxMatch { tabScore += 3 }
|
|
578
|
+
if processMatch { tabScore += 2 }
|
|
579
|
+
|
|
580
|
+
if let wid = inst.windowId {
|
|
581
|
+
if var acc = byWid[wid] {
|
|
582
|
+
acc.score += tabScore
|
|
583
|
+
if cwdMatch && !acc.sources.contains("cwd") { acc.sources.append("cwd") }
|
|
584
|
+
if tabMatch && !acc.sources.contains("tab") { acc.sources.append("tab") }
|
|
585
|
+
if tmuxMatch && !acc.sources.contains("tmux") { acc.sources.append("tmux") }
|
|
586
|
+
if processMatch && !acc.sources.contains("process") { acc.sources.append("process") }
|
|
587
|
+
acc.tabs.append(tabJson)
|
|
588
|
+
byWid[wid] = acc
|
|
589
|
+
} else if let entry = desktop.windows[wid] {
|
|
590
|
+
guard passesTimeFilter(wid: wid, entry: entry) else { continue }
|
|
591
|
+
var matchSources: [String] = []
|
|
592
|
+
if cwdMatch { matchSources.append("cwd") }
|
|
593
|
+
if tabMatch { matchSources.append("tab") }
|
|
594
|
+
if tmuxMatch { matchSources.append("tmux") }
|
|
595
|
+
if processMatch { matchSources.append("process") }
|
|
596
|
+
let score = tabScore + recencyBoost(wid: wid, zIndex: entry.zIndex)
|
|
597
|
+
byWid[wid] = Accum(
|
|
598
|
+
entry: entry, score: score, sources: matchSources,
|
|
599
|
+
ocrSnippet: nil, tabs: [tabJson],
|
|
600
|
+
lastInteraction: desktop.lastInteractionDate(for: wid)
|
|
601
|
+
)
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
// ── Build results ──
|
|
608
|
+
|
|
609
|
+
let sorted = byWid.values.sorted { $0.score > $1.score }
|
|
610
|
+
return .array(Array(sorted.prefix(limit)).map { acc in
|
|
611
|
+
var obj = Encoders.window(acc.entry)
|
|
612
|
+
if case .object(var dict) = obj {
|
|
613
|
+
dict["score"] = .int(acc.score)
|
|
614
|
+
dict["matchSources"] = .array(acc.sources.map { .string($0) })
|
|
615
|
+
if let snippet = acc.ocrSnippet { dict["ocrSnippet"] = .string(snippet) }
|
|
616
|
+
if !acc.tabs.isEmpty { dict["terminalTabs"] = .array(acc.tabs) }
|
|
617
|
+
if let last = acc.lastInteraction {
|
|
618
|
+
dict["lastInteraction"] = .string(ISO8601DateFormatter().string(from: last))
|
|
619
|
+
}
|
|
620
|
+
obj = .object(dict)
|
|
621
|
+
}
|
|
622
|
+
return obj
|
|
623
|
+
})
|
|
624
|
+
}
|
|
625
|
+
))
|
|
626
|
+
|
|
374
627
|
// MARK: - Window Layer Tags
|
|
375
628
|
|
|
376
629
|
api.register(Endpoint(
|
|
@@ -379,7 +632,7 @@ final class LatticesApi {
|
|
|
379
632
|
access: .mutate,
|
|
380
633
|
params: [
|
|
381
634
|
Param(name: "wid", type: "uint32", required: true, description: "Window ID"),
|
|
382
|
-
Param(name: "layer", type: "string", required: true, description: "Layer id (e.g. 'lattices', '
|
|
635
|
+
Param(name: "layer", type: "string", required: true, description: "Layer id (e.g. 'lattices', 'vox')")
|
|
383
636
|
],
|
|
384
637
|
returns: .ok,
|
|
385
638
|
handler: { params in
|
|
@@ -756,22 +1009,18 @@ final class LatticesApi {
|
|
|
756
1009
|
params: [
|
|
757
1010
|
Param(name: "session", type: "string", required: true, description: "Tmux session name"),
|
|
758
1011
|
Param(name: "position", type: "string", required: true,
|
|
759
|
-
description: "
|
|
1012
|
+
description: "Placement shorthand or grid syntax"),
|
|
760
1013
|
],
|
|
761
1014
|
returns: .ok,
|
|
762
1015
|
handler: { params in
|
|
763
|
-
guard
|
|
1016
|
+
guard case .object(var dict) = params else {
|
|
764
1017
|
throw RouterError.missingParam("session")
|
|
765
1018
|
}
|
|
766
|
-
guard
|
|
767
|
-
|
|
768
|
-
throw RouterError.missingParam("position (valid: \(TilePosition.allCases.map(\.rawValue).joined(separator: ", ")))")
|
|
769
|
-
}
|
|
770
|
-
let terminal = Preferences.shared.terminal
|
|
771
|
-
DispatchQueue.main.async {
|
|
772
|
-
WindowTiler.tile(session: session, terminal: terminal, to: position)
|
|
1019
|
+
guard dict["session"]?.stringValue != nil else {
|
|
1020
|
+
throw RouterError.missingParam("session")
|
|
773
1021
|
}
|
|
774
|
-
|
|
1022
|
+
dict["placement"] = dict["placement"] ?? dict["position"]
|
|
1023
|
+
return try Self.executeWindowPlacement(params: .object(dict))
|
|
775
1024
|
}
|
|
776
1025
|
))
|
|
777
1026
|
|
|
@@ -789,10 +1038,16 @@ final class LatticesApi {
|
|
|
789
1038
|
guard let entry = DesktopModel.shared.windows[wid] else {
|
|
790
1039
|
throw RouterError.notFound("window \(wid)")
|
|
791
1040
|
}
|
|
792
|
-
|
|
793
|
-
|
|
1041
|
+
var raised = false
|
|
1042
|
+
if Thread.isMainThread {
|
|
1043
|
+
raised = WindowTiler.focusWindow(wid: wid, pid: entry.pid)
|
|
1044
|
+
} else {
|
|
1045
|
+
DispatchQueue.main.sync {
|
|
1046
|
+
raised = WindowTiler.focusWindow(wid: wid, pid: entry.pid)
|
|
1047
|
+
}
|
|
794
1048
|
}
|
|
795
|
-
return .object(["ok": .bool(
|
|
1049
|
+
return .object(["ok": .bool(raised), "wid": .int(Int(wid)), "app": .string(entry.app),
|
|
1050
|
+
"raised": .bool(raised)])
|
|
796
1051
|
}
|
|
797
1052
|
guard let session = params?["session"]?.stringValue else {
|
|
798
1053
|
throw RouterError.missingParam("session or wid")
|
|
@@ -805,6 +1060,83 @@ final class LatticesApi {
|
|
|
805
1060
|
}
|
|
806
1061
|
))
|
|
807
1062
|
|
|
1063
|
+
api.register(Endpoint(
|
|
1064
|
+
method: "window.place",
|
|
1065
|
+
description: "Place a window or session using a typed placement spec",
|
|
1066
|
+
access: .mutate,
|
|
1067
|
+
params: [
|
|
1068
|
+
Param(name: "wid", type: "uint32", required: false, description: "Window ID"),
|
|
1069
|
+
Param(name: "session", type: "string", required: false, description: "Tmux session name"),
|
|
1070
|
+
Param(name: "app", type: "string", required: false, description: "Application name"),
|
|
1071
|
+
Param(name: "title", type: "string", required: false, description: "Optional title substring for app matching"),
|
|
1072
|
+
Param(name: "display", type: "int", required: false, description: "Target display index"),
|
|
1073
|
+
Param(name: "placement", type: "string|object", required: true, description: "Placement shorthand or typed placement object"),
|
|
1074
|
+
],
|
|
1075
|
+
returns: .custom("Execution receipt with target resolution, placement, and trace"),
|
|
1076
|
+
handler: { params in
|
|
1077
|
+
try Self.executeWindowPlacement(params: params)
|
|
1078
|
+
}
|
|
1079
|
+
))
|
|
1080
|
+
|
|
1081
|
+
// ── Present Window ────────────────────────────────────────────
|
|
1082
|
+
api.register(Endpoint(
|
|
1083
|
+
method: "window.present",
|
|
1084
|
+
description: "Present a window: move to current space, bring to front, optionally position it",
|
|
1085
|
+
access: .mutate,
|
|
1086
|
+
params: [
|
|
1087
|
+
Param(name: "wid", type: "uint32", required: true, description: "Window ID"),
|
|
1088
|
+
Param(name: "x", type: "double", required: false, description: "Target x position"),
|
|
1089
|
+
Param(name: "y", type: "double", required: false, description: "Target y position"),
|
|
1090
|
+
Param(name: "w", type: "double", required: false, description: "Target width"),
|
|
1091
|
+
Param(name: "h", type: "double", required: false, description: "Target height"),
|
|
1092
|
+
Param(name: "position", type: "string", required: false,
|
|
1093
|
+
description: "Tile position (e.g. center, left, right, bottom-right)"),
|
|
1094
|
+
],
|
|
1095
|
+
returns: .ok,
|
|
1096
|
+
handler: { params in
|
|
1097
|
+
guard let wid = params?["wid"]?.uint32Value else {
|
|
1098
|
+
throw RouterError.missingParam("wid")
|
|
1099
|
+
}
|
|
1100
|
+
guard let entry = DesktopModel.shared.windows[wid] else {
|
|
1101
|
+
throw RouterError.notFound("window \(wid)")
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
// Resolve position to fractional rect
|
|
1105
|
+
var fractions: (CGFloat, CGFloat, CGFloat, CGFloat)? = nil
|
|
1106
|
+
if let placement = Self.parsePlacement(from: params?["placement"] ?? params?["position"]) {
|
|
1107
|
+
fractions = placement.fractions
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
var frame: CGRect? = nil
|
|
1111
|
+
if let fracs = fractions {
|
|
1112
|
+
let screen = Self.resolveTargetScreen(for: entry, displayIndex: params?["display"]?.intValue)
|
|
1113
|
+
// Compute pixel frame (needs main thread for NSScreen)
|
|
1114
|
+
if Thread.isMainThread {
|
|
1115
|
+
frame = WindowTiler.tileFrame(fractions: fracs, on: screen)
|
|
1116
|
+
} else {
|
|
1117
|
+
DispatchQueue.main.sync {
|
|
1118
|
+
frame = WindowTiler.tileFrame(fractions: fracs, on: screen)
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
} else if let x = params?["x"]?.intValue,
|
|
1122
|
+
let y = params?["y"]?.intValue,
|
|
1123
|
+
let w = params?["w"]?.intValue,
|
|
1124
|
+
let h = params?["h"]?.intValue {
|
|
1125
|
+
frame = CGRect(x: x, y: y, width: w, height: h)
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
var presented = false
|
|
1129
|
+
if Thread.isMainThread {
|
|
1130
|
+
presented = WindowTiler.present(wid: wid, pid: entry.pid, frame: frame)
|
|
1131
|
+
} else {
|
|
1132
|
+
DispatchQueue.main.sync {
|
|
1133
|
+
presented = WindowTiler.present(wid: wid, pid: entry.pid, frame: frame)
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
return .object(["ok": .bool(presented), "wid": .int(Int(wid)), "app": .string(entry.app)])
|
|
1137
|
+
}
|
|
1138
|
+
))
|
|
1139
|
+
|
|
808
1140
|
api.register(Endpoint(
|
|
809
1141
|
method: "window.move",
|
|
810
1142
|
description: "Move a session's window to a different space",
|
|
@@ -929,20 +1261,27 @@ final class LatticesApi {
|
|
|
929
1261
|
],
|
|
930
1262
|
returns: .ok,
|
|
931
1263
|
handler: { params in
|
|
932
|
-
|
|
933
|
-
let
|
|
934
|
-
|
|
935
|
-
index = i
|
|
936
|
-
} else if let n = params?["name"]?.stringValue, let i = wm.layerIndex(named: n) {
|
|
937
|
-
index = i
|
|
938
|
-
} else {
|
|
939
|
-
throw RouterError.missingParam("index or name")
|
|
1264
|
+
var dict: [String: JSON] = [:]
|
|
1265
|
+
if case .object(let obj) = params {
|
|
1266
|
+
dict = obj
|
|
940
1267
|
}
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
1268
|
+
dict["mode"] = dict["mode"] ?? .string("launch")
|
|
1269
|
+
return try Self.executeLayerActivation(params: .object(dict))
|
|
1270
|
+
}
|
|
1271
|
+
))
|
|
1272
|
+
|
|
1273
|
+
api.register(Endpoint(
|
|
1274
|
+
method: "layer.activate",
|
|
1275
|
+
description: "Activate a workspace layer using an explicit activation mode",
|
|
1276
|
+
access: .mutate,
|
|
1277
|
+
params: [
|
|
1278
|
+
Param(name: "index", type: "int", required: false, description: "Layer index"),
|
|
1279
|
+
Param(name: "name", type: "string", required: false, description: "Layer id or label (case-insensitive)"),
|
|
1280
|
+
Param(name: "mode", type: "string", required: false, description: "Activation mode: launch, focus, or retile"),
|
|
1281
|
+
],
|
|
1282
|
+
returns: .custom("Execution receipt with resolved layer, activation mode, and trace"),
|
|
1283
|
+
handler: { params in
|
|
1284
|
+
try Self.executeLayerActivation(params: params)
|
|
946
1285
|
}
|
|
947
1286
|
))
|
|
948
1287
|
|
|
@@ -1000,20 +1339,525 @@ final class LatticesApi {
|
|
|
1000
1339
|
|
|
1001
1340
|
api.register(Endpoint(
|
|
1002
1341
|
method: "layout.distribute",
|
|
1003
|
-
description: "Distribute
|
|
1342
|
+
description: "Distribute windows evenly in a grid, optionally filtered by app and constrained to a screen region",
|
|
1343
|
+
access: .mutate,
|
|
1344
|
+
params: [
|
|
1345
|
+
Param(name: "app", type: "string", required: false, description: "Filter to windows of this app (e.g. 'iTerm2')"),
|
|
1346
|
+
Param(name: "region", type: "string", required: false, description: "Constrain grid to a screen region (e.g. 'right', 'left', 'top-right'). Uses tile position names."),
|
|
1347
|
+
],
|
|
1348
|
+
returns: .ok,
|
|
1349
|
+
handler: { params in
|
|
1350
|
+
var dict: [String: JSON] = [:]
|
|
1351
|
+
if case .object(let obj) = params {
|
|
1352
|
+
dict = obj
|
|
1353
|
+
}
|
|
1354
|
+
// If app is provided, switch to app scope
|
|
1355
|
+
if dict["app"] != nil && dict["scope"] == nil {
|
|
1356
|
+
dict["scope"] = .string("app")
|
|
1357
|
+
} else {
|
|
1358
|
+
dict["scope"] = dict["scope"] ?? .string("visible")
|
|
1359
|
+
}
|
|
1360
|
+
dict["strategy"] = dict["strategy"] ?? .string("balanced")
|
|
1361
|
+
return try Self.executeSpaceOptimization(params: .object(dict))
|
|
1362
|
+
}
|
|
1363
|
+
))
|
|
1364
|
+
|
|
1365
|
+
api.register(Endpoint(
|
|
1366
|
+
method: "space.optimize",
|
|
1367
|
+
description: "Optimize a set of windows using an explicit scope and strategy",
|
|
1368
|
+
access: .mutate,
|
|
1369
|
+
params: [
|
|
1370
|
+
Param(name: "scope", type: "string", required: false, description: "Optimization scope: visible, active-app, app, or selection"),
|
|
1371
|
+
Param(name: "strategy", type: "string", required: false, description: "Optimization strategy: balanced or mosaic"),
|
|
1372
|
+
Param(name: "app", type: "string", required: false, description: "App name for app-scoped optimization"),
|
|
1373
|
+
Param(name: "title", type: "string", required: false, description: "Optional title substring for app-scoped optimization"),
|
|
1374
|
+
Param(name: "windowIds", type: "[uint32]", required: false, description: "Explicit window selection for selection scope"),
|
|
1375
|
+
],
|
|
1376
|
+
returns: .custom("Execution receipt with scope, strategy, resolved windows, and trace"),
|
|
1377
|
+
handler: { params in
|
|
1378
|
+
try Self.executeSpaceOptimization(params: params)
|
|
1379
|
+
}
|
|
1380
|
+
))
|
|
1381
|
+
|
|
1382
|
+
// ── Session Layers ────────────────────────────────────────
|
|
1383
|
+
|
|
1384
|
+
api.model(ApiModel(name: "WindowRef", fields: [
|
|
1385
|
+
Field(name: "id", type: "string", required: true, description: "Stable UUID for this ref"),
|
|
1386
|
+
Field(name: "app", type: "string", required: true, description: "Application name"),
|
|
1387
|
+
Field(name: "contentHint", type: "string", required: false, description: "Title substring hint for matching"),
|
|
1388
|
+
Field(name: "tile", type: "string", required: false, description: "Intended tile position"),
|
|
1389
|
+
Field(name: "display", type: "int", required: false, description: "Intended display index"),
|
|
1390
|
+
Field(name: "wid", type: "int", required: false, description: "Resolved CGWindowID"),
|
|
1391
|
+
Field(name: "pid", type: "int", required: false, description: "Resolved process ID"),
|
|
1392
|
+
Field(name: "title", type: "string", required: false, description: "Resolved window title"),
|
|
1393
|
+
Field(name: "frame", type: "Frame", required: false, description: "Resolved window frame"),
|
|
1394
|
+
]))
|
|
1395
|
+
|
|
1396
|
+
api.model(ApiModel(name: "SessionLayer", fields: [
|
|
1397
|
+
Field(name: "id", type: "string", required: true, description: "Layer UUID"),
|
|
1398
|
+
Field(name: "name", type: "string", required: true, description: "Layer display name"),
|
|
1399
|
+
Field(name: "windows", type: "[WindowRef]", required: true, description: "Window references in this layer"),
|
|
1400
|
+
]))
|
|
1401
|
+
|
|
1402
|
+
api.register(Endpoint(
|
|
1403
|
+
method: "session.layers.create",
|
|
1404
|
+
description: "Create a named session layer with optional window references",
|
|
1405
|
+
access: .mutate,
|
|
1406
|
+
params: [
|
|
1407
|
+
Param(name: "name", type: "string", required: true, description: "Layer name"),
|
|
1408
|
+
Param(name: "windowIds", type: "[uint32]", required: false, description: "Window IDs to include"),
|
|
1409
|
+
Param(name: "windows", type: "[object]", required: false, description: "Window refs as {app, contentHint}"),
|
|
1410
|
+
],
|
|
1411
|
+
returns: .object(model: "SessionLayer"),
|
|
1412
|
+
handler: { params in
|
|
1413
|
+
guard let name = params?["name"]?.stringValue, !name.isEmpty else {
|
|
1414
|
+
throw RouterError.missingParam("name")
|
|
1415
|
+
}
|
|
1416
|
+
var refs: [WindowRef] = []
|
|
1417
|
+
|
|
1418
|
+
// Build refs from windowIds
|
|
1419
|
+
if case .array(let ids) = params?["windowIds"] {
|
|
1420
|
+
for idJson in ids {
|
|
1421
|
+
if let wid = idJson.uint32Value, let entry = DesktopModel.shared.windows[wid] {
|
|
1422
|
+
refs.append(WindowRef(
|
|
1423
|
+
app: entry.app, contentHint: entry.title,
|
|
1424
|
+
wid: entry.wid, pid: entry.pid, title: entry.title, frame: entry.frame
|
|
1425
|
+
))
|
|
1426
|
+
}
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
// Build refs from windows array
|
|
1431
|
+
if case .array(let winSpecs) = params?["windows"] {
|
|
1432
|
+
for spec in winSpecs {
|
|
1433
|
+
guard let app = spec["app"]?.stringValue else { continue }
|
|
1434
|
+
let hint = spec["contentHint"]?.stringValue
|
|
1435
|
+
var ref = WindowRef(app: app, contentHint: hint)
|
|
1436
|
+
// Try to resolve immediately
|
|
1437
|
+
if let entry = DesktopModel.shared.windowForApp(app: app, title: hint) {
|
|
1438
|
+
ref.wid = entry.wid
|
|
1439
|
+
ref.pid = entry.pid
|
|
1440
|
+
ref.title = entry.title
|
|
1441
|
+
ref.frame = entry.frame
|
|
1442
|
+
}
|
|
1443
|
+
refs.append(ref)
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
let layer = SessionLayerStore.shared.create(name: name, windows: refs)
|
|
1448
|
+
// Update layer tags
|
|
1449
|
+
for ref in refs {
|
|
1450
|
+
if let wid = ref.wid {
|
|
1451
|
+
DesktopModel.shared.assignLayer(wid: wid, layerId: name)
|
|
1452
|
+
}
|
|
1453
|
+
}
|
|
1454
|
+
return Encoders.sessionLayer(layer)
|
|
1455
|
+
}
|
|
1456
|
+
))
|
|
1457
|
+
|
|
1458
|
+
api.register(Endpoint(
|
|
1459
|
+
method: "session.layers.delete",
|
|
1460
|
+
description: "Delete a session layer by id or name",
|
|
1461
|
+
access: .mutate,
|
|
1462
|
+
params: [
|
|
1463
|
+
Param(name: "id", type: "string", required: false, description: "Layer UUID"),
|
|
1464
|
+
Param(name: "name", type: "string", required: false, description: "Layer name"),
|
|
1465
|
+
],
|
|
1466
|
+
returns: .ok,
|
|
1467
|
+
handler: { params in
|
|
1468
|
+
let store = SessionLayerStore.shared
|
|
1469
|
+
if let id = params?["id"]?.stringValue {
|
|
1470
|
+
store.delete(id: id)
|
|
1471
|
+
} else if let name = params?["name"]?.stringValue, let layer = store.layerByName(name) {
|
|
1472
|
+
store.delete(id: layer.id)
|
|
1473
|
+
} else {
|
|
1474
|
+
throw RouterError.missingParam("id or name")
|
|
1475
|
+
}
|
|
1476
|
+
return .object(["ok": .bool(true)])
|
|
1477
|
+
}
|
|
1478
|
+
))
|
|
1479
|
+
|
|
1480
|
+
api.register(Endpoint(
|
|
1481
|
+
method: "session.layers.list",
|
|
1482
|
+
description: "List all session layers with resolved window info",
|
|
1483
|
+
access: .read,
|
|
1484
|
+
params: [],
|
|
1485
|
+
returns: .custom("Object with 'layers' array and 'activeIndex'"),
|
|
1486
|
+
handler: { _ in
|
|
1487
|
+
let store = SessionLayerStore.shared
|
|
1488
|
+
return .object([
|
|
1489
|
+
"layers": .array(store.layers.map { Encoders.sessionLayer($0) }),
|
|
1490
|
+
"activeIndex": .int(store.activeIndex)
|
|
1491
|
+
])
|
|
1492
|
+
}
|
|
1493
|
+
))
|
|
1494
|
+
|
|
1495
|
+
api.register(Endpoint(
|
|
1496
|
+
method: "session.layers.assign",
|
|
1497
|
+
description: "Add window ref(s) to a session layer",
|
|
1498
|
+
access: .mutate,
|
|
1499
|
+
params: [
|
|
1500
|
+
Param(name: "layerId", type: "string", required: false, description: "Layer UUID"),
|
|
1501
|
+
Param(name: "layerName", type: "string", required: false, description: "Layer name"),
|
|
1502
|
+
Param(name: "wid", type: "uint32", required: false, description: "Single window ID to add"),
|
|
1503
|
+
Param(name: "windowIds", type: "[uint32]", required: false, description: "Multiple window IDs to add"),
|
|
1504
|
+
Param(name: "window", type: "object", required: false, description: "Window ref as {app, contentHint}"),
|
|
1505
|
+
],
|
|
1506
|
+
returns: .ok,
|
|
1507
|
+
handler: { params in
|
|
1508
|
+
let store = SessionLayerStore.shared
|
|
1509
|
+
let layerId: String
|
|
1510
|
+
if let id = params?["layerId"]?.stringValue {
|
|
1511
|
+
layerId = id
|
|
1512
|
+
} else if let name = params?["layerName"]?.stringValue, let layer = store.layerByName(name) {
|
|
1513
|
+
layerId = layer.id
|
|
1514
|
+
} else {
|
|
1515
|
+
throw RouterError.missingParam("layerId or layerName")
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
if let wid = params?["wid"]?.uint32Value {
|
|
1519
|
+
store.assignByWid(wid, toLayerId: layerId)
|
|
1520
|
+
}
|
|
1521
|
+
if case .array(let ids) = params?["windowIds"] {
|
|
1522
|
+
for idJson in ids {
|
|
1523
|
+
if let wid = idJson.uint32Value {
|
|
1524
|
+
store.assignByWid(wid, toLayerId: layerId)
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
}
|
|
1528
|
+
if let spec = params?["window"] {
|
|
1529
|
+
if let app = spec["app"]?.stringValue {
|
|
1530
|
+
let hint = spec["contentHint"]?.stringValue
|
|
1531
|
+
var ref = WindowRef(app: app, contentHint: hint)
|
|
1532
|
+
if let entry = DesktopModel.shared.windowForApp(app: app, title: hint) {
|
|
1533
|
+
ref.wid = entry.wid
|
|
1534
|
+
ref.pid = entry.pid
|
|
1535
|
+
ref.title = entry.title
|
|
1536
|
+
ref.frame = entry.frame
|
|
1537
|
+
}
|
|
1538
|
+
store.assign(ref: ref, toLayerId: layerId)
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
return .object(["ok": .bool(true)])
|
|
1542
|
+
}
|
|
1543
|
+
))
|
|
1544
|
+
|
|
1545
|
+
api.register(Endpoint(
|
|
1546
|
+
method: "session.layers.remove",
|
|
1547
|
+
description: "Remove window ref(s) from a session layer",
|
|
1548
|
+
access: .mutate,
|
|
1549
|
+
params: [
|
|
1550
|
+
Param(name: "layerId", type: "string", required: false, description: "Layer UUID"),
|
|
1551
|
+
Param(name: "layerName", type: "string", required: false, description: "Layer name"),
|
|
1552
|
+
Param(name: "refId", type: "string", required: true, description: "WindowRef ID to remove"),
|
|
1553
|
+
],
|
|
1554
|
+
returns: .ok,
|
|
1555
|
+
handler: { params in
|
|
1556
|
+
let store = SessionLayerStore.shared
|
|
1557
|
+
let layerId: String
|
|
1558
|
+
if let id = params?["layerId"]?.stringValue {
|
|
1559
|
+
layerId = id
|
|
1560
|
+
} else if let name = params?["layerName"]?.stringValue, let layer = store.layerByName(name) {
|
|
1561
|
+
layerId = layer.id
|
|
1562
|
+
} else {
|
|
1563
|
+
throw RouterError.missingParam("layerId or layerName")
|
|
1564
|
+
}
|
|
1565
|
+
guard let refId = params?["refId"]?.stringValue else {
|
|
1566
|
+
throw RouterError.missingParam("refId")
|
|
1567
|
+
}
|
|
1568
|
+
store.remove(refId: refId, fromLayerId: layerId)
|
|
1569
|
+
return .object(["ok": .bool(true)])
|
|
1570
|
+
}
|
|
1571
|
+
))
|
|
1572
|
+
|
|
1573
|
+
api.register(Endpoint(
|
|
1574
|
+
method: "session.layers.switch",
|
|
1575
|
+
description: "Switch to a session layer by index or name",
|
|
1576
|
+
access: .mutate,
|
|
1577
|
+
params: [
|
|
1578
|
+
Param(name: "index", type: "int", required: false, description: "Layer index"),
|
|
1579
|
+
Param(name: "name", type: "string", required: false, description: "Layer name"),
|
|
1580
|
+
],
|
|
1581
|
+
returns: .ok,
|
|
1582
|
+
handler: { params in
|
|
1583
|
+
let store = SessionLayerStore.shared
|
|
1584
|
+
let index: Int
|
|
1585
|
+
if let i = params?["index"]?.intValue {
|
|
1586
|
+
index = i
|
|
1587
|
+
} else if let name = params?["name"]?.stringValue,
|
|
1588
|
+
let i = store.layers.firstIndex(where: { $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame }) {
|
|
1589
|
+
index = i
|
|
1590
|
+
} else {
|
|
1591
|
+
throw RouterError.missingParam("index or name")
|
|
1592
|
+
}
|
|
1593
|
+
DispatchQueue.main.async {
|
|
1594
|
+
store.switchTo(index: index)
|
|
1595
|
+
}
|
|
1596
|
+
return .object(["ok": .bool(true)])
|
|
1597
|
+
}
|
|
1598
|
+
))
|
|
1599
|
+
|
|
1600
|
+
api.register(Endpoint(
|
|
1601
|
+
method: "session.layers.rename",
|
|
1602
|
+
description: "Rename a session layer",
|
|
1603
|
+
access: .mutate,
|
|
1604
|
+
params: [
|
|
1605
|
+
Param(name: "id", type: "string", required: false, description: "Layer UUID"),
|
|
1606
|
+
Param(name: "oldName", type: "string", required: false, description: "Current layer name"),
|
|
1607
|
+
Param(name: "name", type: "string", required: true, description: "New layer name"),
|
|
1608
|
+
],
|
|
1609
|
+
returns: .ok,
|
|
1610
|
+
handler: { params in
|
|
1611
|
+
let store = SessionLayerStore.shared
|
|
1612
|
+
guard let newName = params?["name"]?.stringValue, !newName.isEmpty else {
|
|
1613
|
+
throw RouterError.missingParam("name")
|
|
1614
|
+
}
|
|
1615
|
+
if let id = params?["id"]?.stringValue {
|
|
1616
|
+
store.rename(id: id, name: newName)
|
|
1617
|
+
} else if let oldName = params?["oldName"]?.stringValue, let layer = store.layerByName(oldName) {
|
|
1618
|
+
store.rename(id: layer.id, name: newName)
|
|
1619
|
+
} else {
|
|
1620
|
+
throw RouterError.missingParam("id or oldName")
|
|
1621
|
+
}
|
|
1622
|
+
return .object(["ok": .bool(true)])
|
|
1623
|
+
}
|
|
1624
|
+
))
|
|
1625
|
+
|
|
1626
|
+
api.register(Endpoint(
|
|
1627
|
+
method: "session.layers.clear",
|
|
1628
|
+
description: "Clear all session layers",
|
|
1629
|
+
access: .mutate,
|
|
1630
|
+
params: [],
|
|
1631
|
+
returns: .ok,
|
|
1632
|
+
handler: { _ in
|
|
1633
|
+
SessionLayerStore.shared.clear()
|
|
1634
|
+
return .object(["ok": .bool(true)])
|
|
1635
|
+
}
|
|
1636
|
+
))
|
|
1637
|
+
|
|
1638
|
+
// ── Intents ───────────────────────────────────────────────
|
|
1639
|
+
|
|
1640
|
+
api.model(ApiModel(name: "IntentSlot", fields: [
|
|
1641
|
+
Field(name: "name", type: "string", required: true, description: "Slot name"),
|
|
1642
|
+
Field(name: "type", type: "string", required: true, description: "Slot type (string, int, position, query, bool)"),
|
|
1643
|
+
Field(name: "required", type: "bool", required: true, description: "Whether the slot is required"),
|
|
1644
|
+
Field(name: "description", type: "string", required: true, description: "Slot description"),
|
|
1645
|
+
Field(name: "values", type: "[string]", required: false, description: "Allowed values for enum slots"),
|
|
1646
|
+
]))
|
|
1647
|
+
|
|
1648
|
+
api.model(ApiModel(name: "IntentDef", fields: [
|
|
1649
|
+
Field(name: "intent", type: "string", required: true, description: "Intent identifier"),
|
|
1650
|
+
Field(name: "description", type: "string", required: true, description: "What the intent does"),
|
|
1651
|
+
Field(name: "examples", type: "[string]", required: true, description: "Example phrases"),
|
|
1652
|
+
Field(name: "slots", type: "[IntentSlot]", required: true, description: "Named parameters"),
|
|
1653
|
+
]))
|
|
1654
|
+
|
|
1655
|
+
api.register(Endpoint(
|
|
1656
|
+
method: "intents.list",
|
|
1657
|
+
description: "List all available intents with their slots and example phrases",
|
|
1658
|
+
access: .read,
|
|
1659
|
+
params: [],
|
|
1660
|
+
returns: .array(model: "IntentDef"),
|
|
1661
|
+
handler: { _ in
|
|
1662
|
+
IntentEngine.shared.catalog()
|
|
1663
|
+
}
|
|
1664
|
+
))
|
|
1665
|
+
|
|
1666
|
+
api.register(Endpoint(
|
|
1667
|
+
method: "intents.execute",
|
|
1668
|
+
description: "Execute a structured intent (from voice, agent, or script)",
|
|
1669
|
+
access: .mutate,
|
|
1670
|
+
params: [
|
|
1671
|
+
Param(name: "intent", type: "string", required: true, description: "Intent name (e.g. 'tile_window', 'focus', 'launch')"),
|
|
1672
|
+
Param(name: "slots", type: "object", required: false, description: "Named parameters for the intent"),
|
|
1673
|
+
Param(name: "rawText", type: "string", required: false, description: "Original transcription text"),
|
|
1674
|
+
Param(name: "confidence", type: "double", required: false, description: "Transcription confidence (0-1)"),
|
|
1675
|
+
Param(name: "source", type: "string", required: false, description: "Source of the intent (e.g. 'vox', 'siri', 'cli')"),
|
|
1676
|
+
],
|
|
1677
|
+
returns: .custom("Intent-specific result"),
|
|
1678
|
+
handler: { params in
|
|
1679
|
+
guard let intentName = params?["intent"]?.stringValue else {
|
|
1680
|
+
throw RouterError.missingParam("intent")
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
// Extract slots
|
|
1684
|
+
var slots: [String: JSON] = [:]
|
|
1685
|
+
if case .object(let obj) = params?["slots"] {
|
|
1686
|
+
slots = obj
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
let request = IntentRequest(
|
|
1690
|
+
intent: intentName,
|
|
1691
|
+
slots: slots,
|
|
1692
|
+
rawText: params?["rawText"]?.stringValue,
|
|
1693
|
+
confidence: params?["confidence"]?.numericDouble,
|
|
1694
|
+
source: params?["source"]?.stringValue
|
|
1695
|
+
)
|
|
1696
|
+
|
|
1697
|
+
return try IntentEngine.shared.execute(request)
|
|
1698
|
+
}
|
|
1699
|
+
))
|
|
1700
|
+
|
|
1701
|
+
// ── Voice / Audio ─────────────────────────────────────────
|
|
1702
|
+
|
|
1703
|
+
api.register(Endpoint(
|
|
1704
|
+
method: "voice.status",
|
|
1705
|
+
description: "Check audio provider status (e.g. Vox availability)",
|
|
1706
|
+
access: .read,
|
|
1707
|
+
params: [],
|
|
1708
|
+
returns: .custom("Provider status with name and listening state"),
|
|
1709
|
+
handler: { _ in
|
|
1710
|
+
let audio = AudioLayer.shared
|
|
1711
|
+
return .object([
|
|
1712
|
+
"provider": .string(audio.providerName),
|
|
1713
|
+
"available": .bool(audio.provider?.isAvailable ?? false),
|
|
1714
|
+
"listening": .bool(audio.isListening),
|
|
1715
|
+
"lastTranscript": audio.lastTranscript.map { .string($0) } ?? .null
|
|
1716
|
+
])
|
|
1717
|
+
}
|
|
1718
|
+
))
|
|
1719
|
+
|
|
1720
|
+
api.register(Endpoint(
|
|
1721
|
+
method: "voice.listen",
|
|
1722
|
+
description: "Start voice capture via the audio provider (e.g. Vox)",
|
|
1723
|
+
access: .mutate,
|
|
1724
|
+
params: [],
|
|
1725
|
+
returns: .ok,
|
|
1726
|
+
handler: { _ in
|
|
1727
|
+
guard AudioLayer.shared.provider != nil else {
|
|
1728
|
+
throw RouterError.custom("No audio provider available. Is Vox running?")
|
|
1729
|
+
}
|
|
1730
|
+
DispatchQueue.main.async {
|
|
1731
|
+
AudioLayer.shared.startVoiceCommand()
|
|
1732
|
+
}
|
|
1733
|
+
return .object(["ok": .bool(true), "provider": .string(AudioLayer.shared.providerName)])
|
|
1734
|
+
}
|
|
1735
|
+
))
|
|
1736
|
+
|
|
1737
|
+
api.register(Endpoint(
|
|
1738
|
+
method: "voice.stop",
|
|
1739
|
+
description: "Stop voice capture and process the transcription",
|
|
1004
1740
|
access: .mutate,
|
|
1005
1741
|
params: [],
|
|
1006
1742
|
returns: .ok,
|
|
1007
1743
|
handler: { _ in
|
|
1008
1744
|
DispatchQueue.main.async {
|
|
1009
|
-
|
|
1745
|
+
AudioLayer.shared.stopVoiceCommand()
|
|
1010
1746
|
}
|
|
1011
1747
|
return .object(["ok": .bool(true)])
|
|
1012
1748
|
}
|
|
1013
1749
|
))
|
|
1014
1750
|
|
|
1751
|
+
api.register(Endpoint(
|
|
1752
|
+
method: "voice.simulate",
|
|
1753
|
+
description: "Simulate a voice command: parse text into an intent and execute it",
|
|
1754
|
+
access: .mutate,
|
|
1755
|
+
params: [
|
|
1756
|
+
Param(name: "text", type: "string", required: true, description: "Voice command text (as if transcribed)"),
|
|
1757
|
+
Param(name: "execute", type: "bool", required: false, description: "Actually execute the intent (default true)"),
|
|
1758
|
+
],
|
|
1759
|
+
returns: .custom("Parsed intent with execution result"),
|
|
1760
|
+
handler: { params in
|
|
1761
|
+
guard let text = params?["text"]?.stringValue, !text.isEmpty else {
|
|
1762
|
+
throw RouterError.missingParam("text")
|
|
1763
|
+
}
|
|
1764
|
+
let shouldExecute = params?["execute"]?.boolValue ?? true
|
|
1765
|
+
|
|
1766
|
+
let matcher = PhraseMatcher.shared
|
|
1767
|
+
guard let matched = matcher.match(text: text) else {
|
|
1768
|
+
return .object([
|
|
1769
|
+
"parsed": .bool(false),
|
|
1770
|
+
"text": .string(text),
|
|
1771
|
+
"intent": .null,
|
|
1772
|
+
"message": .string("No intent matched")
|
|
1773
|
+
])
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
var response: [String: JSON] = [
|
|
1777
|
+
"parsed": .bool(true),
|
|
1778
|
+
"text": .string(text),
|
|
1779
|
+
"intent": .string(matched.intentName),
|
|
1780
|
+
"slots": .object(matched.slots),
|
|
1781
|
+
"confidence": .double(matched.confidence),
|
|
1782
|
+
]
|
|
1783
|
+
|
|
1784
|
+
if shouldExecute {
|
|
1785
|
+
do {
|
|
1786
|
+
let result = try matcher.execute(matched)
|
|
1787
|
+
response["executed"] = .bool(true)
|
|
1788
|
+
response["result"] = result
|
|
1789
|
+
} catch {
|
|
1790
|
+
response["executed"] = .bool(false)
|
|
1791
|
+
response["error"] = .string(error.localizedDescription)
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
return .object(response)
|
|
1796
|
+
}
|
|
1797
|
+
))
|
|
1798
|
+
|
|
1799
|
+
api.register(Endpoint(
|
|
1800
|
+
method: "voice.reconnect",
|
|
1801
|
+
description: "Force disconnect and reconnect the Vox WebSocket connection",
|
|
1802
|
+
access: .mutate,
|
|
1803
|
+
params: [],
|
|
1804
|
+
returns: .custom("Reconnection initiated with previous and new connection state"),
|
|
1805
|
+
handler: { _ in
|
|
1806
|
+
let client = VoxClient.shared
|
|
1807
|
+
let previousState = "\(client.connectionState)"
|
|
1808
|
+
DispatchQueue.main.async {
|
|
1809
|
+
client.reconnect()
|
|
1810
|
+
}
|
|
1811
|
+
return .object([
|
|
1812
|
+
"ok": .bool(true),
|
|
1813
|
+
"previousState": .string(previousState),
|
|
1814
|
+
"action": .string("reconnecting"),
|
|
1815
|
+
])
|
|
1816
|
+
}
|
|
1817
|
+
))
|
|
1818
|
+
|
|
1015
1819
|
// ── Meta endpoint ───────────────────────────────────────
|
|
1016
1820
|
|
|
1821
|
+
// ── Mouse Finder ────────────────────────────────────────
|
|
1822
|
+
|
|
1823
|
+
api.register(Endpoint(
|
|
1824
|
+
method: "mouse.find",
|
|
1825
|
+
description: "Show a sonar pulse at the current mouse cursor position",
|
|
1826
|
+
access: .read,
|
|
1827
|
+
params: [],
|
|
1828
|
+
returns: .ok,
|
|
1829
|
+
handler: { _ in
|
|
1830
|
+
DispatchQueue.main.async { MouseFinder.shared.find() }
|
|
1831
|
+
let pos = NSEvent.mouseLocation
|
|
1832
|
+
return .object(["ok": .bool(true), "x": .int(Int(pos.x)), "y": .int(Int(pos.y))])
|
|
1833
|
+
}
|
|
1834
|
+
))
|
|
1835
|
+
|
|
1836
|
+
api.register(Endpoint(
|
|
1837
|
+
method: "mouse.summon",
|
|
1838
|
+
description: "Warp the mouse cursor to screen center (or a given point) and show a sonar pulse",
|
|
1839
|
+
access: .mutate,
|
|
1840
|
+
params: [
|
|
1841
|
+
Param(name: "x", type: "int", required: false, description: "Target X coordinate (screen, bottom-left origin)"),
|
|
1842
|
+
Param(name: "y", type: "int", required: false, description: "Target Y coordinate (screen, bottom-left origin)"),
|
|
1843
|
+
],
|
|
1844
|
+
returns: .ok,
|
|
1845
|
+
handler: { params in
|
|
1846
|
+
let target: CGPoint?
|
|
1847
|
+
if let x = params?["x"]?.intValue, let y = params?["y"]?.intValue {
|
|
1848
|
+
target = CGPoint(x: CGFloat(x), y: CGFloat(y))
|
|
1849
|
+
} else {
|
|
1850
|
+
target = nil
|
|
1851
|
+
}
|
|
1852
|
+
DispatchQueue.main.async { MouseFinder.shared.summon(to: target) }
|
|
1853
|
+
let pos = target ?? {
|
|
1854
|
+
let screen = NSScreen.main ?? NSScreen.screens[0]
|
|
1855
|
+
return CGPoint(x: screen.frame.midX, y: screen.frame.midY)
|
|
1856
|
+
}()
|
|
1857
|
+
return .object(["ok": .bool(true), "x": .int(Int(pos.x)), "y": .int(Int(pos.y))])
|
|
1858
|
+
}
|
|
1859
|
+
))
|
|
1860
|
+
|
|
1017
1861
|
api.register(Endpoint(
|
|
1018
1862
|
method: "api.schema",
|
|
1019
1863
|
description: "Get the full API schema including all methods and models",
|
|
@@ -1027,6 +1871,376 @@ final class LatticesApi {
|
|
|
1027
1871
|
}
|
|
1028
1872
|
}
|
|
1029
1873
|
|
|
1874
|
+
private extension LatticesApi {
|
|
1875
|
+
static func parsePlacement(from json: JSON?) -> PlacementSpec? {
|
|
1876
|
+
PlacementSpec(json: json)
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
static func resolveTargetScreen(for entry: WindowEntry?, displayIndex: Int?) -> NSScreen {
|
|
1880
|
+
if let displayIndex, displayIndex >= 0, displayIndex < NSScreen.screens.count {
|
|
1881
|
+
return NSScreen.screens[displayIndex]
|
|
1882
|
+
}
|
|
1883
|
+
if let entry {
|
|
1884
|
+
return WindowTiler.screenForWindowFrame(entry.frame)
|
|
1885
|
+
}
|
|
1886
|
+
return NSScreen.main ?? NSScreen.screens[0]
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
static func frontmostWindowTarget() -> (wid: UInt32, pid: Int32)? {
|
|
1890
|
+
guard let app = NSWorkspace.shared.frontmostApplication,
|
|
1891
|
+
app.bundleIdentifier != "com.arach.lattices" else {
|
|
1892
|
+
return nil
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
let appRef = AXUIElementCreateApplication(app.processIdentifier)
|
|
1896
|
+
var focusedRef: CFTypeRef?
|
|
1897
|
+
guard AXUIElementCopyAttributeValue(appRef, kAXFocusedWindowAttribute as CFString, &focusedRef) == .success,
|
|
1898
|
+
let focusedWindow = focusedRef else {
|
|
1899
|
+
return nil
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
var wid: CGWindowID = 0
|
|
1903
|
+
guard _AXUIElementGetWindow(focusedWindow as! AXUIElement, &wid) == .success else {
|
|
1904
|
+
return nil
|
|
1905
|
+
}
|
|
1906
|
+
return (UInt32(wid), app.processIdentifier)
|
|
1907
|
+
}
|
|
1908
|
+
|
|
1909
|
+
static func executeWindowPlacement(params: JSON?) throws -> JSON {
|
|
1910
|
+
guard let placement = parsePlacement(from: params?["placement"] ?? params?["position"]) else {
|
|
1911
|
+
throw RouterError.missingParam("placement")
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
let displayIndex = params?["display"]?.intValue
|
|
1915
|
+
var trace: [JSON] = []
|
|
1916
|
+
|
|
1917
|
+
if let wid = params?["wid"]?.uint32Value {
|
|
1918
|
+
guard let entry = DesktopModel.shared.windows[wid] else {
|
|
1919
|
+
throw RouterError.notFound("window \(wid)")
|
|
1920
|
+
}
|
|
1921
|
+
let screen = resolveTargetScreen(for: entry, displayIndex: displayIndex)
|
|
1922
|
+
trace.append(.string("resolved target by wid"))
|
|
1923
|
+
trace.append(.string("placement \(placement.wireValue)"))
|
|
1924
|
+
DispatchQueue.main.async {
|
|
1925
|
+
WindowTiler.tileWindowById(wid: wid, pid: entry.pid, to: placement, on: screen)
|
|
1926
|
+
}
|
|
1927
|
+
return .object([
|
|
1928
|
+
"ok": .bool(true),
|
|
1929
|
+
"target": .string("wid"),
|
|
1930
|
+
"wid": .int(Int(wid)),
|
|
1931
|
+
"app": .string(entry.app),
|
|
1932
|
+
"placement": placement.jsonValue,
|
|
1933
|
+
"trace": .array(trace),
|
|
1934
|
+
])
|
|
1935
|
+
}
|
|
1936
|
+
|
|
1937
|
+
if let session = params?["session"]?.stringValue {
|
|
1938
|
+
let screen = resolveTargetScreen(
|
|
1939
|
+
for: DesktopModel.shared.windowForSession(session),
|
|
1940
|
+
displayIndex: displayIndex
|
|
1941
|
+
)
|
|
1942
|
+
trace.append(.string("resolved target by session"))
|
|
1943
|
+
trace.append(.string("placement \(placement.wireValue)"))
|
|
1944
|
+
|
|
1945
|
+
if let entry = DesktopModel.shared.windowForSession(session) {
|
|
1946
|
+
DispatchQueue.main.async {
|
|
1947
|
+
WindowTiler.tileWindowById(wid: entry.wid, pid: entry.pid, to: placement, on: screen)
|
|
1948
|
+
}
|
|
1949
|
+
return .object([
|
|
1950
|
+
"ok": .bool(true),
|
|
1951
|
+
"target": .string("session"),
|
|
1952
|
+
"session": .string(session),
|
|
1953
|
+
"wid": .int(Int(entry.wid)),
|
|
1954
|
+
"placement": placement.jsonValue,
|
|
1955
|
+
"trace": .array(trace),
|
|
1956
|
+
])
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
let terminal = Preferences.shared.terminal
|
|
1960
|
+
trace.append(.string("session window not in DesktopModel; using terminal fallback"))
|
|
1961
|
+
DispatchQueue.main.async {
|
|
1962
|
+
WindowTiler.tile(session: session, terminal: terminal, to: placement, on: screen)
|
|
1963
|
+
}
|
|
1964
|
+
return .object([
|
|
1965
|
+
"ok": .bool(true),
|
|
1966
|
+
"target": .string("session"),
|
|
1967
|
+
"session": .string(session),
|
|
1968
|
+
"placement": placement.jsonValue,
|
|
1969
|
+
"trace": .array(trace),
|
|
1970
|
+
])
|
|
1971
|
+
}
|
|
1972
|
+
|
|
1973
|
+
if let app = params?["app"]?.stringValue {
|
|
1974
|
+
let title = params?["title"]?.stringValue
|
|
1975
|
+
guard let entry = DesktopModel.shared.windowForApp(app: app, title: title) else {
|
|
1976
|
+
throw RouterError.notFound("window for app \(app)")
|
|
1977
|
+
}
|
|
1978
|
+
let screen = resolveTargetScreen(for: entry, displayIndex: displayIndex)
|
|
1979
|
+
trace.append(.string("resolved target by app/title match"))
|
|
1980
|
+
trace.append(.string("placement \(placement.wireValue)"))
|
|
1981
|
+
DispatchQueue.main.async {
|
|
1982
|
+
WindowTiler.tileWindowById(wid: entry.wid, pid: entry.pid, to: placement, on: screen)
|
|
1983
|
+
}
|
|
1984
|
+
return .object([
|
|
1985
|
+
"ok": .bool(true),
|
|
1986
|
+
"target": .string("app"),
|
|
1987
|
+
"app": .string(entry.app),
|
|
1988
|
+
"wid": .int(Int(entry.wid)),
|
|
1989
|
+
"placement": placement.jsonValue,
|
|
1990
|
+
"trace": .array(trace),
|
|
1991
|
+
])
|
|
1992
|
+
}
|
|
1993
|
+
|
|
1994
|
+
if let target = frontmostWindowTarget() {
|
|
1995
|
+
let wid = target.wid
|
|
1996
|
+
let entry = DesktopModel.shared.windows[wid]
|
|
1997
|
+
let screen = resolveTargetScreen(for: entry, displayIndex: displayIndex)
|
|
1998
|
+
trace.append(.string("resolved target by frontmost window"))
|
|
1999
|
+
trace.append(.string("placement \(placement.wireValue)"))
|
|
2000
|
+
DispatchQueue.main.async {
|
|
2001
|
+
WindowTiler.tileWindowById(wid: wid, pid: target.pid, to: placement, on: screen)
|
|
2002
|
+
}
|
|
2003
|
+
|
|
2004
|
+
var response: [String: JSON] = [
|
|
2005
|
+
"ok": .bool(true),
|
|
2006
|
+
"target": .string("frontmost"),
|
|
2007
|
+
"wid": .int(Int(wid)),
|
|
2008
|
+
"placement": placement.jsonValue,
|
|
2009
|
+
"trace": .array(trace),
|
|
2010
|
+
]
|
|
2011
|
+
if let entry {
|
|
2012
|
+
response["app"] = .string(entry.app)
|
|
2013
|
+
}
|
|
2014
|
+
return .object(response)
|
|
2015
|
+
}
|
|
2016
|
+
|
|
2017
|
+
throw RouterError.custom("Could not resolve a window target for placement")
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
static func executeLayerActivation(params: JSON?) throws -> JSON {
|
|
2021
|
+
let wm = WorkspaceManager.shared
|
|
2022
|
+
guard let layers = wm.config?.layers, !layers.isEmpty else {
|
|
2023
|
+
throw RouterError.notFound("workspace layers")
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
let index: Int
|
|
2027
|
+
var trace: [JSON] = []
|
|
2028
|
+
|
|
2029
|
+
if let value = params?["index"]?.intValue {
|
|
2030
|
+
index = value
|
|
2031
|
+
trace.append(.string("resolved layer by index"))
|
|
2032
|
+
} else if let name = params?["name"]?.stringValue, let value = wm.layerIndex(named: name) {
|
|
2033
|
+
index = value
|
|
2034
|
+
trace.append(.string("resolved layer by name"))
|
|
2035
|
+
} else {
|
|
2036
|
+
throw RouterError.missingParam("index or name")
|
|
2037
|
+
}
|
|
2038
|
+
|
|
2039
|
+
guard index >= 0, index < layers.count else {
|
|
2040
|
+
throw RouterError.notFound("layer \(index)")
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
let mode = try parseLayerActivationMode(params?["mode"]?.stringValue)
|
|
2044
|
+
let layer = layers[index]
|
|
2045
|
+
let previousIndex = wm.activeLayerIndex
|
|
2046
|
+
trace.append(.string("activation mode \(mode)"))
|
|
2047
|
+
|
|
2048
|
+
DispatchQueue.main.async {
|
|
2049
|
+
switch mode {
|
|
2050
|
+
case "focus":
|
|
2051
|
+
wm.focusLayer(index: index)
|
|
2052
|
+
case "retile":
|
|
2053
|
+
wm.tileLayer(index: index, launch: false, force: true)
|
|
2054
|
+
default:
|
|
2055
|
+
wm.tileLayer(index: index, launch: true, force: true)
|
|
2056
|
+
}
|
|
2057
|
+
|
|
2058
|
+
if previousIndex != index || mode != "focus" {
|
|
2059
|
+
EventBus.shared.post(.layerSwitched(index: index))
|
|
2060
|
+
}
|
|
2061
|
+
}
|
|
2062
|
+
|
|
2063
|
+
return .object([
|
|
2064
|
+
"ok": .bool(true),
|
|
2065
|
+
"index": .int(index),
|
|
2066
|
+
"id": .string(layer.id),
|
|
2067
|
+
"label": .string(layer.label),
|
|
2068
|
+
"mode": .string(mode),
|
|
2069
|
+
"trace": .array(trace),
|
|
2070
|
+
])
|
|
2071
|
+
}
|
|
2072
|
+
|
|
2073
|
+
static func executeSpaceOptimization(params: JSON?) throws -> JSON {
|
|
2074
|
+
let scope = try parseOptimizationScope(from: params)
|
|
2075
|
+
let strategy = try parseOptimizationStrategy(params?["strategy"]?.stringValue)
|
|
2076
|
+
var trace: [JSON] = [.string("resolved scope \(scope)"), .string("resolved strategy \(strategy)")]
|
|
2077
|
+
let windows = resolveOptimizationTargets(scope: scope, params: params, trace: &trace)
|
|
2078
|
+
|
|
2079
|
+
// Resolve optional region constraint (e.g. "right" → right half of screen)
|
|
2080
|
+
var region: (CGFloat, CGFloat, CGFloat, CGFloat)? = nil
|
|
2081
|
+
if let regionStr = params?["region"]?.stringValue {
|
|
2082
|
+
if let spec = PlacementSpec(string: regionStr) {
|
|
2083
|
+
region = spec.fractions
|
|
2084
|
+
trace.append(.string("region \(regionStr) → fractions \(spec.fractions)"))
|
|
2085
|
+
} else {
|
|
2086
|
+
trace.append(.string("unknown region \(regionStr), using full screen"))
|
|
2087
|
+
}
|
|
2088
|
+
}
|
|
2089
|
+
|
|
2090
|
+
if strategy == "mosaic" {
|
|
2091
|
+
trace.append(.string("strategy mosaic currently uses the smart-grid distributor"))
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
guard !windows.isEmpty else {
|
|
2095
|
+
trace.append(.string("no eligible windows resolved"))
|
|
2096
|
+
return .object([
|
|
2097
|
+
"ok": .bool(true),
|
|
2098
|
+
"scope": .string(scope),
|
|
2099
|
+
"strategy": .string(strategy),
|
|
2100
|
+
"windowCount": .int(0),
|
|
2101
|
+
"wids": .array([]),
|
|
2102
|
+
"trace": .array(trace),
|
|
2103
|
+
])
|
|
2104
|
+
}
|
|
2105
|
+
|
|
2106
|
+
let targets = windows.map { (wid: $0.wid, pid: $0.pid) }
|
|
2107
|
+
DispatchQueue.main.async {
|
|
2108
|
+
WindowTiler.batchRaiseAndDistribute(windows: targets, region: region)
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
return .object([
|
|
2112
|
+
"ok": .bool(true),
|
|
2113
|
+
"scope": .string(scope),
|
|
2114
|
+
"strategy": .string(strategy),
|
|
2115
|
+
"windowCount": .int(windows.count),
|
|
2116
|
+
"wids": .array(windows.map { .int(Int($0.wid)) }),
|
|
2117
|
+
"trace": .array(trace),
|
|
2118
|
+
])
|
|
2119
|
+
}
|
|
2120
|
+
|
|
2121
|
+
static func parseLayerActivationMode(_ raw: String?) throws -> String {
|
|
2122
|
+
let mode = normalizeToken(raw ?? "launch")
|
|
2123
|
+
switch mode {
|
|
2124
|
+
case "launch", "focus", "retile":
|
|
2125
|
+
return mode
|
|
2126
|
+
default:
|
|
2127
|
+
throw RouterError.custom("Unsupported layer activation mode: \(raw ?? mode)")
|
|
2128
|
+
}
|
|
2129
|
+
}
|
|
2130
|
+
|
|
2131
|
+
static func parseOptimizationScope(from params: JSON?) throws -> String {
|
|
2132
|
+
if params?["windowIds"] != nil {
|
|
2133
|
+
return "selection"
|
|
2134
|
+
}
|
|
2135
|
+
if params?["app"] != nil {
|
|
2136
|
+
return "app"
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
let scope = normalizeToken(params?["scope"]?.stringValue ?? "visible")
|
|
2140
|
+
switch scope {
|
|
2141
|
+
case "visible", "selection", "app", "active-app", "frontmost-app", "current-app":
|
|
2142
|
+
return scope
|
|
2143
|
+
default:
|
|
2144
|
+
throw RouterError.custom("Unsupported optimization scope: \(params?["scope"]?.stringValue ?? scope)")
|
|
2145
|
+
}
|
|
2146
|
+
}
|
|
2147
|
+
|
|
2148
|
+
static func parseOptimizationStrategy(_ raw: String?) throws -> String {
|
|
2149
|
+
let strategy = normalizeToken(raw ?? "balanced")
|
|
2150
|
+
switch strategy {
|
|
2151
|
+
case "balanced", "mosaic":
|
|
2152
|
+
return strategy
|
|
2153
|
+
default:
|
|
2154
|
+
throw RouterError.custom("Unsupported optimization strategy: \(raw ?? strategy)")
|
|
2155
|
+
}
|
|
2156
|
+
}
|
|
2157
|
+
|
|
2158
|
+
static func resolveOptimizationTargets(scope: String, params: JSON?, trace: inout [JSON]) -> [WindowEntry] {
|
|
2159
|
+
let visible = distributableWindows()
|
|
2160
|
+
let titleFilter = params?["title"]?.stringValue
|
|
2161
|
+
|
|
2162
|
+
switch scope {
|
|
2163
|
+
case "selection":
|
|
2164
|
+
let ids = selectedWindowIds(from: params?["windowIds"])
|
|
2165
|
+
trace.append(.string("selection size \(ids.count)"))
|
|
2166
|
+
return dedupeWindows(visible.filter { ids.contains($0.wid) })
|
|
2167
|
+
|
|
2168
|
+
case "app":
|
|
2169
|
+
guard let app = params?["app"]?.stringValue else {
|
|
2170
|
+
trace.append(.string("missing app for app scope"))
|
|
2171
|
+
return []
|
|
2172
|
+
}
|
|
2173
|
+
trace.append(.string("filtered by app \(app)"))
|
|
2174
|
+
if let titleFilter {
|
|
2175
|
+
trace.append(.string("title contains \(titleFilter)"))
|
|
2176
|
+
}
|
|
2177
|
+
return dedupeWindows(visible.filter {
|
|
2178
|
+
$0.app.localizedCaseInsensitiveCompare(app) == .orderedSame &&
|
|
2179
|
+
(titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
|
|
2180
|
+
})
|
|
2181
|
+
|
|
2182
|
+
case "active-app", "frontmost-app", "current-app":
|
|
2183
|
+
let activeApp = params?["app"]?.stringValue ?? frontmostOptimizableApp()
|
|
2184
|
+
guard let activeApp else {
|
|
2185
|
+
trace.append(.string("no active app available"))
|
|
2186
|
+
return []
|
|
2187
|
+
}
|
|
2188
|
+
trace.append(.string("resolved active app \(activeApp)"))
|
|
2189
|
+
if let titleFilter {
|
|
2190
|
+
trace.append(.string("title contains \(titleFilter)"))
|
|
2191
|
+
}
|
|
2192
|
+
return dedupeWindows(visible.filter {
|
|
2193
|
+
$0.app.localizedCaseInsensitiveCompare(activeApp) == .orderedSame &&
|
|
2194
|
+
(titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
|
|
2195
|
+
})
|
|
2196
|
+
|
|
2197
|
+
default:
|
|
2198
|
+
trace.append(.string("using visible window scope"))
|
|
2199
|
+
return dedupeWindows(visible)
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
|
|
2203
|
+
static func selectedWindowIds(from json: JSON?) -> [UInt32] {
|
|
2204
|
+
guard case .array(let values) = json else { return [] }
|
|
2205
|
+
return values.compactMap(\.uint32Value)
|
|
2206
|
+
}
|
|
2207
|
+
|
|
2208
|
+
static func distributableWindows() -> [WindowEntry] {
|
|
2209
|
+
DesktopModel.shared.allWindows().filter { entry in
|
|
2210
|
+
entry.isOnScreen &&
|
|
2211
|
+
entry.app != "Lattices" &&
|
|
2212
|
+
entry.frame.w > 50 &&
|
|
2213
|
+
entry.frame.h > 50
|
|
2214
|
+
}
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
static func dedupeWindows(_ windows: [WindowEntry]) -> [WindowEntry] {
|
|
2218
|
+
var seen: Set<UInt32> = []
|
|
2219
|
+
var result: [WindowEntry] = []
|
|
2220
|
+
for window in windows where !seen.contains(window.wid) {
|
|
2221
|
+
seen.insert(window.wid)
|
|
2222
|
+
result.append(window)
|
|
2223
|
+
}
|
|
2224
|
+
return result
|
|
2225
|
+
}
|
|
2226
|
+
|
|
2227
|
+
static func frontmostOptimizableApp() -> String? {
|
|
2228
|
+
if let app = NSWorkspace.shared.frontmostApplication?.localizedName,
|
|
2229
|
+
!app.localizedCaseInsensitiveContains("lattices") {
|
|
2230
|
+
return app
|
|
2231
|
+
}
|
|
2232
|
+
return distributableWindows().first?.app
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
static func normalizeToken(_ raw: String) -> String {
|
|
2236
|
+
raw
|
|
2237
|
+
.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
2238
|
+
.lowercased()
|
|
2239
|
+
.replacingOccurrences(of: "_", with: "-")
|
|
2240
|
+
.replacingOccurrences(of: " ", with: "-")
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
|
|
1030
2244
|
// MARK: - Encoders
|
|
1031
2245
|
|
|
1032
2246
|
enum Encoders {
|
|
@@ -1043,7 +2257,8 @@ enum Encoders {
|
|
|
1043
2257
|
"h": .double(w.frame.h)
|
|
1044
2258
|
]),
|
|
1045
2259
|
"spaceIds": .array(w.spaceIds.map { .int($0) }),
|
|
1046
|
-
"isOnScreen": .bool(w.isOnScreen)
|
|
2260
|
+
"isOnScreen": .bool(w.isOnScreen),
|
|
2261
|
+
"axVerified": .bool(w.axVerified)
|
|
1047
2262
|
]
|
|
1048
2263
|
if let session = w.latticesSession {
|
|
1049
2264
|
obj["latticesSession"] = .string(session)
|
|
@@ -1215,6 +2430,34 @@ enum Encoders {
|
|
|
1215
2430
|
if let pm = p.packageManager { obj["packageManager"] = .string(pm) }
|
|
1216
2431
|
return .object(obj)
|
|
1217
2432
|
}
|
|
2433
|
+
|
|
2434
|
+
static func windowRef(_ ref: WindowRef) -> JSON {
|
|
2435
|
+
var obj: [String: JSON] = [
|
|
2436
|
+
"id": .string(ref.id),
|
|
2437
|
+
"app": .string(ref.app),
|
|
2438
|
+
]
|
|
2439
|
+
if let hint = ref.contentHint { obj["contentHint"] = .string(hint) }
|
|
2440
|
+
if let tile = ref.tile { obj["tile"] = .string(tile) }
|
|
2441
|
+
if let display = ref.display { obj["display"] = .int(display) }
|
|
2442
|
+
if let wid = ref.wid { obj["wid"] = .int(Int(wid)) }
|
|
2443
|
+
if let pid = ref.pid { obj["pid"] = .int(Int(pid)) }
|
|
2444
|
+
if let title = ref.title { obj["title"] = .string(title) }
|
|
2445
|
+
if let frame = ref.frame {
|
|
2446
|
+
obj["frame"] = .object([
|
|
2447
|
+
"x": .double(frame.x), "y": .double(frame.y),
|
|
2448
|
+
"w": .double(frame.w), "h": .double(frame.h)
|
|
2449
|
+
])
|
|
2450
|
+
}
|
|
2451
|
+
return .object(obj)
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
static func sessionLayer(_ layer: SessionLayer) -> JSON {
|
|
2455
|
+
.object([
|
|
2456
|
+
"id": .string(layer.id),
|
|
2457
|
+
"name": .string(layer.name),
|
|
2458
|
+
"windows": .array(layer.windows.map { windowRef($0) })
|
|
2459
|
+
])
|
|
2460
|
+
}
|
|
1218
2461
|
}
|
|
1219
2462
|
|
|
1220
2463
|
// MARK: - Errors
|
|
@@ -1223,12 +2466,14 @@ enum RouterError: LocalizedError {
|
|
|
1223
2466
|
case unknownMethod(String)
|
|
1224
2467
|
case missingParam(String)
|
|
1225
2468
|
case notFound(String)
|
|
2469
|
+
case custom(String)
|
|
1226
2470
|
|
|
1227
2471
|
var errorDescription: String? {
|
|
1228
2472
|
switch self {
|
|
1229
2473
|
case .unknownMethod(let m): return "Unknown method: \(m)"
|
|
1230
2474
|
case .missingParam(let p): return "Missing parameter: \(p)"
|
|
1231
2475
|
case .notFound(let what): return "Not found: \(what)"
|
|
2476
|
+
case .custom(let msg): return msg
|
|
1232
2477
|
}
|
|
1233
2478
|
}
|
|
1234
2479
|
}
|