@lattices/cli 0.4.2 → 0.4.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.md +3 -0
  2. package/app/Info.plist +2 -2
  3. package/app/Lattices.app/Contents/Info.plist +2 -2
  4. package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
  5. package/app/Package.swift +6 -0
  6. package/app/Sources/App.swift +10 -0
  7. package/app/Sources/AppDelegate.swift +90 -34
  8. package/app/Sources/AppShellView.swift +2 -0
  9. package/app/Sources/AppTypeClassifier.swift +36 -0
  10. package/app/Sources/AppUpdater.swift +92 -0
  11. package/app/Sources/CheatSheetHUD.swift +1 -0
  12. package/app/Sources/CliActionLauncher.swift +50 -0
  13. package/app/Sources/CommandModeView.swift +4 -24
  14. package/app/Sources/CompanionActivityLog.swift +70 -0
  15. package/app/Sources/CompanionKeyboardController.swift +141 -0
  16. package/app/Sources/DesktopModel.swift +4 -0
  17. package/app/Sources/HandsOffSession.swift +15 -4
  18. package/app/Sources/HomeDashboardView.swift +18 -10
  19. package/app/Sources/HotkeyStore.swift +8 -5
  20. package/app/Sources/IntentEngine.swift +7 -1
  21. package/app/Sources/LatticesApi.swift +125 -4
  22. package/app/Sources/LatticesCompanionBridgeServer.swift +438 -0
  23. package/app/Sources/LatticesCompanionCockpit.swift +555 -0
  24. package/app/Sources/LatticesCompanionSecurityCoordinator.swift +594 -0
  25. package/app/Sources/LatticesCompanionTrackpadController.swift +204 -0
  26. package/app/Sources/LatticesDeckHost.swift +1463 -0
  27. package/app/Sources/LatticesRuntime.swift +61 -0
  28. package/app/Sources/MainView.swift +351 -191
  29. package/app/Sources/MouseFinder.swift +335 -30
  30. package/app/Sources/MouseGestureConfig.swift +364 -0
  31. package/app/Sources/MouseGestureController.swift +1203 -0
  32. package/app/Sources/MouseInputDeviceStore.swift +98 -0
  33. package/app/Sources/MouseInputEventViewer.swift +272 -0
  34. package/app/Sources/MouseShortcutStore.swift +107 -0
  35. package/app/Sources/OmniSearchView.swift +136 -2
  36. package/app/Sources/OmniSearchWindow.swift +65 -5
  37. package/app/Sources/OnboardingView.swift +30 -16
  38. package/app/Sources/PaletteCommand.swift +26 -6
  39. package/app/Sources/PermissionChecker.swift +76 -2
  40. package/app/Sources/PiAuthNextStepCard.swift +148 -0
  41. package/app/Sources/PiAuthPromptCard.swift +90 -0
  42. package/app/Sources/PiChatDock.swift +137 -74
  43. package/app/Sources/PiChatSession.swift +608 -108
  44. package/app/Sources/PiInstallCallout.swift +86 -0
  45. package/app/Sources/PiProviderSetupCallout.swift +99 -0
  46. package/app/Sources/PiWorkspaceView.swift +174 -77
  47. package/app/Sources/Preferences.swift +78 -0
  48. package/app/Sources/ScreenMapState.swift +91 -31
  49. package/app/Sources/ScreenMapView.swift +510 -524
  50. package/app/Sources/ScreenMapWindowController.swift +12 -4
  51. package/app/Sources/SettingsView.swift +869 -152
  52. package/app/Sources/SystemTelemetryMonitor.swift +273 -0
  53. package/app/Sources/VoiceCommandWindow.swift +23 -2
  54. package/app/Sources/WindowDragSnapController.swift +628 -0
  55. package/app/Sources/WindowTiler.swift +328 -65
  56. package/app/Sources/WorkspaceManager.swift +288 -0
  57. package/bin/assistant-intelligence.ts +874 -0
  58. package/bin/handsoff-infer.ts +16 -209
  59. package/bin/handsoff-worker.ts +45 -258
  60. package/bin/lattices-app.ts +62 -0
  61. package/bin/lattices-dev +4 -0
  62. package/bin/lattices.ts +125 -14
  63. package/docs/agents.md +14 -0
  64. package/docs/api.md +55 -0
  65. package/docs/app.md +3 -0
  66. package/docs/companion-deck.md +180 -0
  67. package/docs/config.md +25 -0
  68. package/docs/tiling-reference.md +55 -0
  69. package/docs/voice-error-model.md +73 -0
  70. package/package.json +2 -1
@@ -0,0 +1,141 @@
1
+ import AppKit
2
+ import Foundation
3
+
4
+ enum CompanionKeyboardError: LocalizedError {
5
+ case unknownKey(String)
6
+ case eventSourceUnavailable
7
+
8
+ var errorDescription: String? {
9
+ switch self {
10
+ case .unknownKey(let key):
11
+ return "Unsupported key for forwarding: \(key)"
12
+ case .eventSourceUnavailable:
13
+ return "Unable to create a keyboard event source."
14
+ }
15
+ }
16
+ }
17
+
18
+ final class CompanionKeyboardController {
19
+ static let shared = CompanionKeyboardController()
20
+
21
+ private init() {}
22
+
23
+ func send(key rawKey: String, modifiers rawModifiers: [String]) throws -> String {
24
+ let parsed = parse(key: rawKey, modifiers: rawModifiers)
25
+ guard let keyCode = keyCode(for: parsed.key) else {
26
+ throw CompanionKeyboardError.unknownKey(rawKey)
27
+ }
28
+ guard let source = CGEventSource(stateID: .combinedSessionState) else {
29
+ throw CompanionKeyboardError.eventSourceUnavailable
30
+ }
31
+
32
+ let flags = eventFlags(for: parsed.modifiers)
33
+ guard
34
+ let down = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: true),
35
+ let up = CGEvent(keyboardEventSource: source, virtualKey: keyCode, keyDown: false)
36
+ else {
37
+ throw CompanionKeyboardError.eventSourceUnavailable
38
+ }
39
+
40
+ down.flags = flags
41
+ up.flags = flags
42
+ down.post(tap: .cghidEventTap)
43
+ usleep(12_000)
44
+ up.post(tap: .cghidEventTap)
45
+
46
+ return displayName(key: parsed.key, modifiers: parsed.modifiers)
47
+ }
48
+ }
49
+
50
+ private extension CompanionKeyboardController {
51
+ func parse(key rawKey: String, modifiers rawModifiers: [String]) -> (key: String, modifiers: Set<String>) {
52
+ var modifiers = Set(rawModifiers.map(normalizeModifier).filter { !$0.isEmpty })
53
+ var key = rawKey
54
+ .trimmingCharacters(in: .whitespacesAndNewlines)
55
+ .lowercased()
56
+
57
+ let symbolModifiers: [(String, String)] = [
58
+ ("⌘", "command"),
59
+ ("cmd", "command"),
60
+ ("command", "command"),
61
+ ("⌥", "option"),
62
+ ("option", "option"),
63
+ ("alt", "option"),
64
+ ("⌃", "control"),
65
+ ("ctrl", "control"),
66
+ ("control", "control"),
67
+ ("⇧", "shift"),
68
+ ("shift", "shift"),
69
+ ]
70
+
71
+ for (symbol, modifier) in symbolModifiers where key.contains(symbol) {
72
+ modifiers.insert(modifier)
73
+ key = key.replacingOccurrences(of: symbol, with: "")
74
+ }
75
+
76
+ key = key
77
+ .replacingOccurrences(of: "+", with: "")
78
+ .replacingOccurrences(of: " ", with: "")
79
+ .replacingOccurrences(of: "⎋", with: "escape")
80
+ .replacingOccurrences(of: "⇥", with: "tab")
81
+ .replacingOccurrences(of: "↩", with: "enter")
82
+ .replacingOccurrences(of: "⏎", with: "enter")
83
+ .replacingOccurrences(of: "return", with: "enter")
84
+ .replacingOccurrences(of: "←", with: "left")
85
+ .replacingOccurrences(of: "→", with: "right")
86
+ .replacingOccurrences(of: "↑", with: "up")
87
+ .replacingOccurrences(of: "↓", with: "down")
88
+
89
+ if key == "esc" {
90
+ key = "escape"
91
+ }
92
+
93
+ return (key.isEmpty ? rawKey.lowercased() : key, modifiers)
94
+ }
95
+
96
+ func normalizeModifier(_ modifier: String) -> String {
97
+ switch modifier.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() {
98
+ case "cmd", "command", "meta", "⌘":
99
+ return "command"
100
+ case "opt", "option", "alt", "⌥":
101
+ return "option"
102
+ case "ctrl", "control", "⌃":
103
+ return "control"
104
+ case "shift", "⇧":
105
+ return "shift"
106
+ default:
107
+ return ""
108
+ }
109
+ }
110
+
111
+ func eventFlags(for modifiers: Set<String>) -> CGEventFlags {
112
+ var flags: CGEventFlags = []
113
+ if modifiers.contains("command") { flags.insert(.maskCommand) }
114
+ if modifiers.contains("option") { flags.insert(.maskAlternate) }
115
+ if modifiers.contains("control") { flags.insert(.maskControl) }
116
+ if modifiers.contains("shift") { flags.insert(.maskShift) }
117
+ return flags
118
+ }
119
+
120
+ func keyCode(for key: String) -> CGKeyCode? {
121
+ let codes: [String: CGKeyCode] = [
122
+ "a": 0, "s": 1, "d": 2, "f": 3, "h": 4, "g": 5, "z": 6, "x": 7,
123
+ "c": 8, "v": 9, "b": 11, "q": 12, "w": 13, "e": 14, "r": 15,
124
+ "y": 16, "t": 17, "1": 18, "2": 19, "3": 20, "4": 21, "6": 22,
125
+ "5": 23, "=": 24, "9": 25, "7": 26, "-": 27, "8": 28, "0": 29,
126
+ "]": 30, "o": 31, "u": 32, "[": 33, "i": 34, "p": 35, "enter": 36,
127
+ "l": 37, "j": 38, "'": 39, "k": 40, ";": 41, "\\": 42, ",": 43,
128
+ "/": 44, "n": 45, "m": 46, ".": 47, "tab": 48, "space": 49,
129
+ "`": 50, "delete": 51, "backspace": 51, "escape": 53,
130
+ "command": 55, "cmd": 55, "shift": 56, "capslock": 57, "option": 58,
131
+ "alt": 58, "control": 59, "left": 123, "right": 124, "down": 125,
132
+ "up": 126,
133
+ ]
134
+ return codes[key]
135
+ }
136
+
137
+ func displayName(key: String, modifiers: Set<String>) -> String {
138
+ let ordered = ["control", "option", "shift", "command"].filter { modifiers.contains($0) }
139
+ return (ordered + [key]).joined(separator: "+")
140
+ }
141
+ }
@@ -81,6 +81,10 @@ final class DesktopModel: ObservableObject {
81
81
  Array(windows.values).sorted { $0.zIndex < $1.zIndex }
82
82
  }
83
83
 
84
+ func frontmostWindow() -> WindowEntry? {
85
+ windows.values.min { $0.zIndex < $1.zIndex }
86
+ }
87
+
84
88
  func lastInteractionDate(for wid: UInt32) -> Date? {
85
89
  interactionDates[wid]
86
90
  }
@@ -43,7 +43,14 @@ final class HandsOffSession: ObservableObject {
43
43
  case thinking
44
44
  }
45
45
 
46
- @Published var state: State = .idle
46
+ @Published var state: State = .idle {
47
+ didSet {
48
+ if state != oldValue {
49
+ stateChangedAt = Date()
50
+ }
51
+ }
52
+ }
53
+ @Published private(set) var stateChangedAt: Date = Date()
47
54
  @Published var lastTranscript: String?
48
55
  @Published var lastResponse: String?
49
56
  @Published var audibleFeedbackEnabled: Bool = false
@@ -58,6 +65,7 @@ final class HandsOffSession: ObservableObject {
58
65
  let frame: WindowFrame
59
66
  }
60
67
  private(set) var frameHistory: [FrameSnapshot] = []
68
+ private(set) var frameHistoryUpdatedAt: Date?
61
69
 
62
70
  /// Snapshot current frames for all windows that are about to be moved.
63
71
  /// Stores frames in CG/AX coordinates (top-left origin) for direct use with batchRestoreWindows.
@@ -77,10 +85,12 @@ final class HandsOffSession: ObservableObject {
77
85
  break
78
86
  }
79
87
  }
88
+ frameHistoryUpdatedAt = frameHistory.isEmpty ? nil : Date()
80
89
  }
81
90
 
82
91
  func clearFrameHistory() {
83
92
  frameHistory.removeAll()
93
+ frameHistoryUpdatedAt = nil
84
94
  }
85
95
 
86
96
  /// Running chat log — visible in the voice chat panel. Persists across turns.
@@ -327,7 +337,9 @@ final class HandsOffSession: ObservableObject {
327
337
  }
328
338
  }
329
339
 
330
- // Single dispatch — all @Published mutations in one block
340
+ // Single dispatch — all @Published mutations in one block.
341
+ // The pending callback also mutates @Published state, so it must
342
+ // run on main with the rest of the turn completion.
331
343
  DispatchQueue.main.async { [weak self] in
332
344
  guard let self else { return }
333
345
  if let spoken { self.lastResponse = spoken }
@@ -342,9 +354,8 @@ final class HandsOffSession: ObservableObject {
342
354
  self.executeActions(actions)
343
355
  }
344
356
  self.state = .idle
357
+ cb?(json)
345
358
  }
346
-
347
- cb?(json)
348
359
  }
349
360
  }
350
361
 
@@ -4,6 +4,7 @@ struct HomeDashboardView: View {
4
4
  var onNavigate: ((AppPage) -> Void)? = nil
5
5
 
6
6
  @ObservedObject private var scanner = ProjectScanner.shared
7
+ @ObservedObject private var piSession = PiChatSession.shared
7
8
 
8
9
  var body: some View {
9
10
  VStack(spacing: 0) {
@@ -16,6 +17,9 @@ struct HomeDashboardView: View {
16
17
  MainView(scanner: scanner, layout: .embedded)
17
18
  }
18
19
  .background(Palette.bg)
20
+ .onAppear {
21
+ piSession.refreshBinaryAvailability()
22
+ }
19
23
  }
20
24
 
21
25
  private var hero: some View {
@@ -26,7 +30,7 @@ struct HomeDashboardView: View {
26
30
  .font(Typo.heading(18))
27
31
  .foregroundColor(Palette.text)
28
32
 
29
- Text("Launch workspaces, jump into the screen map, or open a full Pi session from one place.")
33
+ Text("Workspace status, project launch, layout, search, and chat in one place.")
30
34
  .font(Typo.mono(11))
31
35
  .foregroundColor(Palette.textDim)
32
36
  .fixedSize(horizontal: false, vertical: true)
@@ -37,8 +41,8 @@ struct HomeDashboardView: View {
37
41
 
38
42
  HStack(spacing: 10) {
39
43
  homeActionCard(
40
- title: "Screen Map",
41
- subtitle: "Spatial window layout",
44
+ title: "Layout",
45
+ subtitle: "Arrange windows",
42
46
  icon: "rectangle.3.group",
43
47
  tint: Palette.running
44
48
  ) {
@@ -46,19 +50,23 @@ struct HomeDashboardView: View {
46
50
  }
47
51
 
48
52
  homeActionCard(
49
- title: "Desktop Inventory",
50
- subtitle: "Enumerate displays and spaces",
51
- icon: "macwindow.on.rectangle",
53
+ title: "Search",
54
+ subtitle: "Find workspace context",
55
+ icon: "magnifyingglass",
52
56
  tint: Palette.detach
53
57
  ) {
54
58
  onNavigate?(.desktopInventory)
55
59
  }
56
60
 
57
61
  homeActionCard(
58
- title: "Pi Workspace",
59
- subtitle: "Standalone conversation surface",
60
- icon: "terminal",
61
- tint: Palette.text
62
+ title: "Chat",
63
+ subtitle: piSession.hasPiBinary
64
+ ? (piSession.needsProviderSetup || piSession.isAuthenticating
65
+ ? piSession.setupStatusSummary
66
+ : "Standalone conversation surface")
67
+ : "Install Pi to enable the assistant",
68
+ icon: "bubble.left.and.bubble.right",
69
+ tint: piSession.hasPiBinary ? Palette.text : Palette.kill
62
70
  ) {
63
71
  onNavigate?(.pi)
64
72
  }
@@ -31,7 +31,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
31
31
  // Tiling
32
32
  case tileLeft, tileRight, tileMaximize, tileCenter
33
33
  case tileTopLeft, tileTopRight, tileBottomLeft, tileBottomRight
34
- case tileTop, tileBottom, tileDistribute
34
+ case tileTop, tileBottom, tileDistribute, tileTypeGrid
35
35
  case tileLeftThird, tileCenterThird, tileRightThird
36
36
 
37
37
  var label: String {
@@ -40,11 +40,11 @@ enum HotkeyAction: String, CaseIterable, Codable {
40
40
  case .screenMap: return "Screen Map"
41
41
  case .bezel: return "Window Bezel"
42
42
  case .cheatSheet: return "Cheat Sheet"
43
- case .desktopInventory: return "Desktop Inventory"
44
- case .omniSearch: return "Omni Search"
43
+ case .desktopInventory: return "Search"
44
+ case .omniSearch: return "Search"
45
45
  case .voiceCommand: return "Voice Command"
46
46
  case .handsOff: return "Hands-Off Mode"
47
- case .unifiedWindow: return "Unified Window"
47
+ case .unifiedWindow: return "Workspace Home"
48
48
  case .hud: return "HUD"
49
49
  case .mouseFinder: return "Find Mouse"
50
50
  case .layer1: return "Layer 1"
@@ -70,6 +70,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
70
70
  case .tileTop: return "Top Half"
71
71
  case .tileBottom: return "Bottom Half"
72
72
  case .tileDistribute: return "Distribute"
73
+ case .tileTypeGrid: return "Grid Type"
73
74
  case .tileLeftThird: return "Left Third"
74
75
  case .tileCenterThird: return "Center Third"
75
76
  case .tileRightThird: return "Right Third"
@@ -122,6 +123,7 @@ enum HotkeyAction: String, CaseIterable, Codable {
122
123
  case .tileTop: return 308
123
124
  case .tileBottom: return 309
124
125
  case .tileDistribute: return 310
126
+ case .tileTypeGrid: return 314
125
127
  case .tileLeftThird: return 311
126
128
  case .tileCenterThird: return 312
127
129
  case .tileRightThird: return 313
@@ -226,7 +228,7 @@ class HotkeyStore: ObservableObject {
226
228
 
227
229
  // App
228
230
  bind(.palette, 46, cmdShift) // Cmd+Shift+M
229
- bind(.unifiedWindow, 18, hyper) // Hyper+1 (Screen Map + Desktop Inventory)
231
+ bind(.unifiedWindow, 18, hyper) // Hyper+1 (Workspace Home)
230
232
  bind(.bezel, 19, hyper) // Hyper+2
231
233
  bind(.hud, 20, hyper) // Hyper+3 (HUD overlay)
232
234
  bind(.voiceCommand, 21, hyper) // Hyper+4 (moved from Hyper+3)
@@ -259,6 +261,7 @@ class HotkeyStore: ObservableObject {
259
261
  bind(.tileBottomLeft, 38, ctrlOpt) // Ctrl+Opt+J
260
262
  bind(.tileBottomRight, 40, ctrlOpt) // Ctrl+Opt+K
261
263
  bind(.tileDistribute, 2, ctrlOpt) // Ctrl+Opt+D
264
+ bind(.tileTypeGrid, 5, hyper) // Hyper+G
262
265
  bind(.tileLeftThird, 18, ctrlOpt) // Ctrl+Opt+1
263
266
  bind(.tileCenterThird, 19, ctrlOpt) // Ctrl+Opt+2
264
267
  bind(.tileRightThird, 20, ctrlOpt) // Ctrl+Opt+3
@@ -381,7 +381,7 @@ final class IntentEngine {
381
381
 
382
382
  register(IntentDef(
383
383
  name: "distribute",
384
- description: "Distribute windows evenly in a grid, optionally filtered by app and constrained to a screen region",
384
+ description: "Distribute windows evenly in a grid, optionally filtered by app or window type and constrained to a screen region",
385
385
  examples: [
386
386
  "spread out the windows",
387
387
  "distribute everything",
@@ -394,6 +394,9 @@ final class IntentEngine {
394
394
  slots: [
395
395
  IntentSlot(name: "app", type: "string", required: false,
396
396
  description: "Filter to windows of this app (e.g. 'iTerm2', 'Google Chrome')", enumValues: nil),
397
+ IntentSlot(name: "type", type: "string", required: false,
398
+ description: "Filter to a window type (e.g. 'terminal', 'browser', 'editor')",
399
+ enumValues: AppType.allCases.map(\.rawValue)),
397
400
  IntentSlot(name: "region", type: "position", required: false,
398
401
  description: "Constrain the grid to a screen region. Uses tile position names.",
399
402
  enumValues: ["left", "right", "top", "bottom", "top-left", "top-right", "bottom-left", "bottom-right",
@@ -404,6 +407,9 @@ final class IntentEngine {
404
407
  if let app = req.slots["app"]?.stringValue {
405
408
  params["app"] = .string(app)
406
409
  }
410
+ if let type = req.slots["type"]?.stringValue {
411
+ params["type"] = .string(type)
412
+ }
407
413
  if let region = req.slots["region"]?.stringValue {
408
414
  params["region"] = .string(region)
409
415
  }
@@ -1,5 +1,6 @@
1
1
  import AppKit
2
2
  import ApplicationServices
3
+ import DeckKit
3
4
  import Foundation
4
5
 
5
6
  // MARK: - Registry Types
@@ -197,6 +198,7 @@ final class LatticesApi {
197
198
  api.model(ApiModel(name: "Space", fields: [
198
199
  Field(name: "id", type: "int", required: true, description: "Space ID"),
199
200
  Field(name: "index", type: "int", required: true, description: "Space index"),
201
+ Field(name: "name", type: "string", required: true, description: "Lattices display name for the space"),
200
202
  Field(name: "display", type: "int", required: true, description: "Display index"),
201
203
  Field(name: "isCurrent", type: "bool", required: true, description: "Whether this is the active space"),
202
204
  ]))
@@ -736,6 +738,7 @@ final class LatticesApi {
736
738
  .object([
737
739
  "id": .int(space.id),
738
740
  "index": .int(space.index),
741
+ "name": .string(Self.defaultSpaceName(for: space.index)),
739
742
  "display": .int(space.display),
740
743
  "isCurrent": .bool(space.isCurrent)
741
744
  ])
@@ -773,6 +776,45 @@ final class LatticesApi {
773
776
  }
774
777
  ))
775
778
 
779
+ api.register(Endpoint(
780
+ method: "deck.manifest",
781
+ description: "Get the shared companion deck manifest exposed by the macOS app",
782
+ access: .read,
783
+ params: [],
784
+ returns: .custom("DeckKit manifest for the Lattices companion surface"),
785
+ handler: { _ in
786
+ try Self.encodeDeckValue(LatticesDeckHost.shared.manifestSync())
787
+ }
788
+ ))
789
+
790
+ api.register(Endpoint(
791
+ method: "deck.snapshot",
792
+ description: "Get the current companion deck runtime snapshot",
793
+ access: .read,
794
+ params: [],
795
+ returns: .custom("DeckKit runtime snapshot with voice, layout, switcher, and history state"),
796
+ handler: { _ in
797
+ try Self.encodeDeckValue(LatticesDeckHost.shared.runtimeSnapshotSync())
798
+ }
799
+ ))
800
+
801
+ api.register(Endpoint(
802
+ method: "deck.perform",
803
+ description: "Perform a companion deck action and return the updated runtime snapshot",
804
+ access: .mutate,
805
+ params: [
806
+ Param(name: "pageID", type: "string", required: false, description: "Deck page ID"),
807
+ Param(name: "actionID", type: "string", required: true, description: "Deck action identifier"),
808
+ Param(name: "payload", type: "object", required: false, description: "Deck action payload"),
809
+ ],
810
+ returns: .custom("DeckKit action result"),
811
+ handler: { params in
812
+ let request = try Self.decodeDeckActionRequest(from: params)
813
+ let result = try LatticesDeckHost.shared.performSync(request)
814
+ return try Self.encodeDeckValue(result)
815
+ }
816
+ ))
817
+
776
818
  api.register(Endpoint(
777
819
  method: "daemon.status",
778
820
  description: "Get daemon status including uptime and counts",
@@ -1339,10 +1381,11 @@ final class LatticesApi {
1339
1381
 
1340
1382
  api.register(Endpoint(
1341
1383
  method: "layout.distribute",
1342
- description: "Distribute windows evenly in a grid, optionally filtered by app and constrained to a screen region",
1384
+ description: "Distribute windows evenly in a grid, optionally filtered by app or type and constrained to a screen region",
1343
1385
  access: .mutate,
1344
1386
  params: [
1345
1387
  Param(name: "app", type: "string", required: false, description: "Filter to windows of this app (e.g. 'iTerm2')"),
1388
+ Param(name: "type", type: "string", required: false, description: "Filter to an app type (e.g. 'terminal', 'browser', 'editor')"),
1346
1389
  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
1390
  ],
1348
1391
  returns: .ok,
@@ -1351,9 +1394,11 @@ final class LatticesApi {
1351
1394
  if case .object(let obj) = params {
1352
1395
  dict = obj
1353
1396
  }
1354
- // If app is provided, switch to app scope
1397
+ // Explicit filters select the matching scope automatically.
1355
1398
  if dict["app"] != nil && dict["scope"] == nil {
1356
1399
  dict["scope"] = .string("app")
1400
+ } else if dict["type"] != nil && dict["scope"] == nil {
1401
+ dict["scope"] = .string("type")
1357
1402
  } else {
1358
1403
  dict["scope"] = dict["scope"] ?? .string("visible")
1359
1404
  }
@@ -1367,9 +1412,10 @@ final class LatticesApi {
1367
1412
  description: "Optimize a set of windows using an explicit scope and strategy",
1368
1413
  access: .mutate,
1369
1414
  params: [
1370
- Param(name: "scope", type: "string", required: false, description: "Optimization scope: visible, active-app, app, or selection"),
1415
+ Param(name: "scope", type: "string", required: false, description: "Optimization scope: visible, active-app, active-type, app, type, or selection"),
1371
1416
  Param(name: "strategy", type: "string", required: false, description: "Optimization strategy: balanced or mosaic"),
1372
1417
  Param(name: "app", type: "string", required: false, description: "App name for app-scoped optimization"),
1418
+ Param(name: "type", type: "string", required: false, description: "App type for type-scoped optimization"),
1373
1419
  Param(name: "title", type: "string", required: false, description: "Optional title substring for app-scoped optimization"),
1374
1420
  Param(name: "windowIds", type: "[uint32]", required: false, description: "Explicit window selection for selection scope"),
1375
1421
  ],
@@ -1872,6 +1918,27 @@ final class LatticesApi {
1872
1918
  }
1873
1919
 
1874
1920
  private extension LatticesApi {
1921
+ static func decodeDeckActionRequest(from json: JSON?) throws -> DeckActionRequest {
1922
+ guard let json else {
1923
+ throw RouterError.missingParam("actionID")
1924
+ }
1925
+ guard case .object(var object) = json else {
1926
+ throw RouterError.custom("Invalid deck action request: params must be an object")
1927
+ }
1928
+ object["payload"] = object["payload"] ?? .object([:])
1929
+ let data = try JSONEncoder().encode(JSON.object(object))
1930
+ do {
1931
+ return try JSONDecoder().decode(DeckActionRequest.self, from: data)
1932
+ } catch {
1933
+ throw RouterError.custom("Invalid deck action request: \(error.localizedDescription)")
1934
+ }
1935
+ }
1936
+
1937
+ static func encodeDeckValue<T: Encodable>(_ value: T) throws -> JSON {
1938
+ let data = try JSONEncoder().encode(value)
1939
+ return try JSONDecoder().decode(JSON.self, from: data)
1940
+ }
1941
+
1875
1942
  static func parsePlacement(from json: JSON?) -> PlacementSpec? {
1876
1943
  PlacementSpec(json: json)
1877
1944
  }
@@ -2070,6 +2137,19 @@ private extension LatticesApi {
2070
2137
  ])
2071
2138
  }
2072
2139
 
2140
+ static func defaultSpaceName(for index: Int) -> String {
2141
+ if let layers = WorkspaceManager.shared.config?.layers,
2142
+ layers.indices.contains(index - 1) {
2143
+ return layers[index - 1].label
2144
+ }
2145
+
2146
+ let defaults = ["main", "code", "chat", "review", "media", "notes", "ops", "admin", "scratch"]
2147
+ if defaults.indices.contains(index - 1) {
2148
+ return defaults[index - 1]
2149
+ }
2150
+ return "space \(index)"
2151
+ }
2152
+
2073
2153
  static func executeSpaceOptimization(params: JSON?) throws -> JSON {
2074
2154
  let scope = try parseOptimizationScope(from: params)
2075
2155
  let strategy = try parseOptimizationStrategy(params?["strategy"]?.stringValue)
@@ -2135,10 +2215,15 @@ private extension LatticesApi {
2135
2215
  if params?["app"] != nil {
2136
2216
  return "app"
2137
2217
  }
2218
+ if params?["type"] != nil {
2219
+ return "type"
2220
+ }
2138
2221
 
2139
2222
  let scope = normalizeToken(params?["scope"]?.stringValue ?? "visible")
2140
2223
  switch scope {
2141
- case "visible", "selection", "app", "active-app", "frontmost-app", "current-app":
2224
+ case "visible", "selection", "app", "type",
2225
+ "active-app", "frontmost-app", "current-app",
2226
+ "active-type", "frontmost-type", "current-type":
2142
2227
  return scope
2143
2228
  default:
2144
2229
  throw RouterError.custom("Unsupported optimization scope: \(params?["scope"]?.stringValue ?? scope)")
@@ -2179,6 +2264,21 @@ private extension LatticesApi {
2179
2264
  (titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
2180
2265
  })
2181
2266
 
2267
+ case "type":
2268
+ guard let typeName = params?["type"]?.stringValue,
2269
+ let appType = parseOptimizationAppType(typeName) else {
2270
+ trace.append(.string("missing or unknown type for type scope"))
2271
+ return []
2272
+ }
2273
+ trace.append(.string("filtered by type \(appType.rawValue)"))
2274
+ if let titleFilter {
2275
+ trace.append(.string("title contains \(titleFilter)"))
2276
+ }
2277
+ return dedupeWindows(visible.filter {
2278
+ AppTypeClassifier.matches($0.app, type: appType) &&
2279
+ (titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
2280
+ })
2281
+
2182
2282
  case "active-app", "frontmost-app", "current-app":
2183
2283
  let activeApp = params?["app"]?.stringValue ?? frontmostOptimizableApp()
2184
2284
  guard let activeApp else {
@@ -2194,12 +2294,33 @@ private extension LatticesApi {
2194
2294
  (titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
2195
2295
  })
2196
2296
 
2297
+ case "active-type", "frontmost-type", "current-type":
2298
+ let activeApp = params?["app"]?.stringValue ?? frontmostOptimizableApp()
2299
+ guard let activeApp else {
2300
+ trace.append(.string("no active app available"))
2301
+ return []
2302
+ }
2303
+ let grouping = AppTypeClassifier.grouping(for: activeApp)
2304
+ trace.append(.string("resolved active type \(grouping.label) from \(activeApp)"))
2305
+ if let titleFilter {
2306
+ trace.append(.string("title contains \(titleFilter)"))
2307
+ }
2308
+ return dedupeWindows(visible.filter {
2309
+ AppTypeClassifier.matches($0.app, grouping: grouping) &&
2310
+ (titleFilter == nil || $0.title.localizedCaseInsensitiveContains(titleFilter!))
2311
+ })
2312
+
2197
2313
  default:
2198
2314
  trace.append(.string("using visible window scope"))
2199
2315
  return dedupeWindows(visible)
2200
2316
  }
2201
2317
  }
2202
2318
 
2319
+ static func parseOptimizationAppType(_ raw: String) -> AppType? {
2320
+ let normalized = normalizeToken(raw)
2321
+ return AppType.allCases.first { $0.rawValue == normalized }
2322
+ }
2323
+
2203
2324
  static func selectedWindowIds(from json: JSON?) -> [UInt32] {
2204
2325
  guard case .array(let values) = json else { return [] }
2205
2326
  return values.compactMap(\.uint32Value)