@lattices/cli 0.4.1 → 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.
- package/README.md +3 -0
- package/app/Info.plist +2 -2
- package/app/Lattices.app/Contents/Info.plist +2 -2
- package/app/Lattices.app/Contents/MacOS/Lattices +0 -0
- package/app/Package.swift +6 -0
- package/app/Sources/ActionRow.swift +43 -26
- package/app/Sources/App.swift +10 -0
- package/app/Sources/AppDelegate.swift +91 -30
- package/app/Sources/AppShellView.swift +2 -0
- package/app/Sources/AppTypeClassifier.swift +36 -0
- package/app/Sources/AppUpdater.swift +92 -0
- package/app/Sources/CheatSheetHUD.swift +1 -0
- package/app/Sources/CliActionLauncher.swift +50 -0
- package/app/Sources/CommandModeView.swift +4 -24
- package/app/Sources/CompanionActivityLog.swift +70 -0
- package/app/Sources/CompanionKeyboardController.swift +141 -0
- package/app/Sources/DesktopModel.swift +4 -0
- package/app/Sources/HandsOffSession.swift +53 -16
- package/app/Sources/HomeDashboardView.swift +18 -10
- package/app/Sources/HotkeyStore.swift +8 -5
- package/app/Sources/IntentEngine.swift +7 -1
- package/app/Sources/LatticesApi.swift +125 -4
- package/app/Sources/LatticesCompanionBridgeServer.swift +438 -0
- package/app/Sources/LatticesCompanionCockpit.swift +555 -0
- package/app/Sources/LatticesCompanionSecurityCoordinator.swift +594 -0
- package/app/Sources/LatticesCompanionTrackpadController.swift +204 -0
- package/app/Sources/LatticesDeckHost.swift +1463 -0
- package/app/Sources/LatticesRuntime.swift +61 -0
- package/app/Sources/MainView.swift +398 -186
- package/app/Sources/MouseFinder.swift +335 -30
- package/app/Sources/MouseGestureConfig.swift +364 -0
- package/app/Sources/MouseGestureController.swift +1203 -0
- package/app/Sources/MouseInputDeviceStore.swift +98 -0
- package/app/Sources/MouseInputEventViewer.swift +272 -0
- package/app/Sources/MouseShortcutStore.swift +107 -0
- package/app/Sources/OmniSearchView.swift +136 -2
- package/app/Sources/OmniSearchWindow.swift +65 -5
- package/app/Sources/OnboardingView.swift +30 -16
- package/app/Sources/PaletteCommand.swift +26 -6
- package/app/Sources/PermissionChecker.swift +76 -2
- package/app/Sources/PiAuthNextStepCard.swift +148 -0
- package/app/Sources/PiAuthPromptCard.swift +90 -0
- package/app/Sources/PiChatDock.swift +137 -74
- package/app/Sources/PiChatSession.swift +608 -108
- package/app/Sources/PiInstallCallout.swift +86 -0
- package/app/Sources/PiProviderSetupCallout.swift +99 -0
- package/app/Sources/PiWorkspaceView.swift +174 -77
- package/app/Sources/Preferences.swift +78 -0
- package/app/Sources/ScreenMapState.swift +91 -31
- package/app/Sources/ScreenMapView.swift +510 -524
- package/app/Sources/ScreenMapWindowController.swift +12 -4
- package/app/Sources/SettingsView.swift +869 -152
- package/app/Sources/SystemTelemetryMonitor.swift +273 -0
- package/app/Sources/VoiceCommandWindow.swift +23 -2
- package/app/Sources/WindowDragSnapController.swift +628 -0
- package/app/Sources/WindowTiler.swift +328 -65
- package/app/Sources/WorkspaceManager.swift +288 -0
- package/bin/assistant-intelligence.ts +874 -0
- package/bin/handsoff-infer.ts +16 -209
- package/bin/handsoff-worker.ts +45 -258
- package/bin/lattices-app.ts +65 -1
- package/bin/lattices-dev +4 -0
- package/bin/lattices.ts +125 -14
- package/docs/agents.md +14 -0
- package/docs/api.md +55 -0
- package/docs/app.md +3 -0
- package/docs/companion-deck.md +180 -0
- package/docs/config.md +25 -0
- package/docs/tiling-reference.md +55 -0
- package/docs/voice-error-model.md +73 -0
- package/package.json +4 -2
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import DeckKit
|
|
2
|
+
import Foundation
|
|
3
|
+
|
|
4
|
+
final class CompanionActivityLog {
|
|
5
|
+
static let shared = CompanionActivityLog()
|
|
6
|
+
|
|
7
|
+
private let lock = NSLock()
|
|
8
|
+
private var entries: [DeckActivityLogEntry] = []
|
|
9
|
+
private let maxEntries = 120
|
|
10
|
+
|
|
11
|
+
private init() {
|
|
12
|
+
EventBus.shared.subscribe { [weak self] event in
|
|
13
|
+
self?.record(event)
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func record(tag: String, tint: String?, text: String) {
|
|
18
|
+
let entry = DeckActivityLogEntry(
|
|
19
|
+
id: UUID().uuidString,
|
|
20
|
+
createdAt: Date(),
|
|
21
|
+
tag: tag,
|
|
22
|
+
tint: tint,
|
|
23
|
+
text: text
|
|
24
|
+
)
|
|
25
|
+
|
|
26
|
+
lock.lock()
|
|
27
|
+
entries.append(entry)
|
|
28
|
+
if entries.count > maxEntries {
|
|
29
|
+
entries.removeFirst(entries.count - maxEntries)
|
|
30
|
+
}
|
|
31
|
+
lock.unlock()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
func snapshot(limit: Int = 80) -> [DeckActivityLogEntry] {
|
|
35
|
+
lock.lock()
|
|
36
|
+
let copy = entries
|
|
37
|
+
lock.unlock()
|
|
38
|
+
|
|
39
|
+
return Array(copy.suffix(limit).reversed())
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private extension CompanionActivityLog {
|
|
44
|
+
func record(_ event: ModelEvent) {
|
|
45
|
+
switch event {
|
|
46
|
+
case .windowsChanged(let windows, let added, let removed):
|
|
47
|
+
let delta = [added.isEmpty ? nil : "+\(added.count)", removed.isEmpty ? nil : "-\(removed.count)"]
|
|
48
|
+
.compactMap { $0 }
|
|
49
|
+
.joined(separator: " ")
|
|
50
|
+
let suffix = delta.isEmpty ? "" : " (\(delta))"
|
|
51
|
+
record(tag: "WIN", tint: "blue", text: "\(windows.count) desktop windows\(suffix)")
|
|
52
|
+
|
|
53
|
+
case .tmuxChanged(let sessions):
|
|
54
|
+
record(tag: "TMUX", tint: "green", text: "\(sessions.count) tmux sessions indexed")
|
|
55
|
+
|
|
56
|
+
case .layerSwitched(let index):
|
|
57
|
+
record(tag: "LAYER", tint: "violet", text: "Switched workspace layer \(index + 1)")
|
|
58
|
+
|
|
59
|
+
case .processesChanged(let interesting):
|
|
60
|
+
record(tag: "PROC", tint: "amber", text: "\(interesting.count) terminal processes changed")
|
|
61
|
+
|
|
62
|
+
case .ocrScanComplete(let windowCount, let totalBlocks):
|
|
63
|
+
record(tag: "OCR", tint: "teal", text: "Scanned \(totalBlocks) text blocks across \(windowCount) windows")
|
|
64
|
+
|
|
65
|
+
case .voiceCommand(let text, let confidence):
|
|
66
|
+
let pct = Int((confidence * 100).rounded())
|
|
67
|
+
record(tag: "VOICE", tint: "red", text: "\"\(text)\" · \(pct)%")
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
@@ -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.
|
|
@@ -97,6 +107,15 @@ final class HandsOffSession: ObservableObject {
|
|
|
97
107
|
private var workerBuffer = ""
|
|
98
108
|
private let workerQueue = DispatchQueue(label: "com.lattices.handsoff-worker", qos: .userInitiated)
|
|
99
109
|
private var lastCueAt: Date = .distantPast
|
|
110
|
+
private var workerRoot: String? {
|
|
111
|
+
if let idx = CommandLine.arguments.firstIndex(of: "--lattices-cli-root"),
|
|
112
|
+
CommandLine.arguments.indices.contains(idx + 1) {
|
|
113
|
+
return CommandLine.arguments[idx + 1]
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let devRoot = NSHomeDirectory() + "/dev/lattices"
|
|
117
|
+
return FileManager.default.fileExists(atPath: devRoot) ? devRoot : nil
|
|
118
|
+
}
|
|
100
119
|
|
|
101
120
|
/// JSONL log for full turn data — ~/.lattices/handsoff.jsonl
|
|
102
121
|
private let turnLogPath = NSHomeDirectory() + "/.lattices/handsoff.jsonl"
|
|
@@ -122,7 +141,7 @@ final class HandsOffSession: ObservableObject {
|
|
|
122
141
|
// MARK: - Lifecycle
|
|
123
142
|
|
|
124
143
|
func start() {
|
|
125
|
-
|
|
144
|
+
// Worker startup is lazy — only start it when a voice turn or cached cue needs it.
|
|
126
145
|
}
|
|
127
146
|
|
|
128
147
|
func setAudibleFeedbackEnabled(_ enabled: Bool) {
|
|
@@ -170,9 +189,10 @@ final class HandsOffSession: ObservableObject {
|
|
|
170
189
|
}
|
|
171
190
|
}
|
|
172
191
|
|
|
173
|
-
|
|
192
|
+
@discardableResult
|
|
193
|
+
private func startWorker() -> Bool {
|
|
174
194
|
if workerProcess?.isRunning == true, workerStdin != nil {
|
|
175
|
-
return
|
|
195
|
+
return true
|
|
176
196
|
}
|
|
177
197
|
|
|
178
198
|
let bunPaths = [
|
|
@@ -182,19 +202,24 @@ final class HandsOffSession: ObservableObject {
|
|
|
182
202
|
]
|
|
183
203
|
guard let bunPath = bunPaths.first(where: { FileManager.default.isExecutableFile(atPath: $0) }) else {
|
|
184
204
|
DiagnosticLog.shared.warn("HandsOff: bun not found, worker disabled")
|
|
185
|
-
return
|
|
205
|
+
return false
|
|
186
206
|
}
|
|
187
207
|
|
|
188
|
-
let
|
|
208
|
+
guard let workerRoot else {
|
|
209
|
+
DiagnosticLog.shared.warn("HandsOff: worker root not found, worker disabled")
|
|
210
|
+
return false
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
let scriptPath = workerRoot + "/bin/handsoff-worker.ts"
|
|
189
214
|
guard FileManager.default.fileExists(atPath: scriptPath) else {
|
|
190
215
|
DiagnosticLog.shared.warn("HandsOff: worker script not found at \(scriptPath)")
|
|
191
|
-
return
|
|
216
|
+
return false
|
|
192
217
|
}
|
|
193
218
|
|
|
194
219
|
let proc = Process()
|
|
195
220
|
proc.executableURL = URL(fileURLWithPath: bunPath)
|
|
196
221
|
proc.arguments = ["run", scriptPath]
|
|
197
|
-
proc.currentDirectoryURL = URL(fileURLWithPath:
|
|
222
|
+
proc.currentDirectoryURL = URL(fileURLWithPath: workerRoot)
|
|
198
223
|
|
|
199
224
|
var env = ProcessInfo.processInfo.environment
|
|
200
225
|
env.removeValue(forKey: "CLAUDECODE")
|
|
@@ -211,7 +236,7 @@ final class HandsOffSession: ObservableObject {
|
|
|
211
236
|
try proc.run()
|
|
212
237
|
} catch {
|
|
213
238
|
DiagnosticLog.shared.warn("HandsOff: failed to start worker — \(error)")
|
|
214
|
-
return
|
|
239
|
+
return false
|
|
215
240
|
}
|
|
216
241
|
|
|
217
242
|
workerProcess = proc
|
|
@@ -235,17 +260,22 @@ final class HandsOffSession: ObservableObject {
|
|
|
235
260
|
|
|
236
261
|
// Handle worker crash → restart
|
|
237
262
|
proc.terminationHandler = { [weak self] proc in
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
263
|
+
guard let self else { return }
|
|
264
|
+
let keepWarm = self.audibleFeedbackEnabled || self.state != .idle
|
|
265
|
+
let suffix = keepWarm ? ", restarting in 2s" : ", staying idle"
|
|
266
|
+
DiagnosticLog.shared.warn("HandsOff: worker exited (code \(proc.terminationStatus))\(suffix)")
|
|
267
|
+
self.workerProcess = nil
|
|
268
|
+
self.workerStdin = nil
|
|
269
|
+
guard keepWarm else { return }
|
|
241
270
|
DispatchQueue.main.asyncAfter(deadline: .now() + 2) {
|
|
242
|
-
self
|
|
271
|
+
self.startWorker()
|
|
243
272
|
}
|
|
244
273
|
}
|
|
245
274
|
|
|
246
275
|
// Ping to verify
|
|
247
276
|
sendToWorker(["cmd": "ping"])
|
|
248
277
|
DiagnosticLog.shared.info("HandsOff: worker started (pid \(proc.processIdentifier))")
|
|
278
|
+
return true
|
|
249
279
|
}
|
|
250
280
|
|
|
251
281
|
// MARK: - Worker communication
|
|
@@ -307,7 +337,9 @@ final class HandsOffSession: ObservableObject {
|
|
|
307
337
|
}
|
|
308
338
|
}
|
|
309
339
|
|
|
310
|
-
// 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.
|
|
311
343
|
DispatchQueue.main.async { [weak self] in
|
|
312
344
|
guard let self else { return }
|
|
313
345
|
if let spoken { self.lastResponse = spoken }
|
|
@@ -322,9 +354,8 @@ final class HandsOffSession: ObservableObject {
|
|
|
322
354
|
self.executeActions(actions)
|
|
323
355
|
}
|
|
324
356
|
self.state = .idle
|
|
357
|
+
cb?(json)
|
|
325
358
|
}
|
|
326
|
-
|
|
327
|
-
cb?(json)
|
|
328
359
|
}
|
|
329
360
|
}
|
|
330
361
|
|
|
@@ -486,6 +517,12 @@ final class HandsOffSession: ObservableObject {
|
|
|
486
517
|
|
|
487
518
|
private func processTurn(_ transcript: String) {
|
|
488
519
|
state = .thinking
|
|
520
|
+
guard startWorker() else {
|
|
521
|
+
state = .idle
|
|
522
|
+
DiagnosticLog.shared.warn("HandsOff: worker unavailable")
|
|
523
|
+
playSound("Basso")
|
|
524
|
+
return
|
|
525
|
+
}
|
|
489
526
|
turnCount += 1
|
|
490
527
|
|
|
491
528
|
let turnStart = Date()
|
|
@@ -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("
|
|
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: "
|
|
41
|
-
subtitle: "
|
|
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: "
|
|
50
|
-
subtitle: "
|
|
51
|
-
icon: "
|
|
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: "
|
|
59
|
-
subtitle:
|
|
60
|
-
|
|
61
|
-
|
|
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 "
|
|
44
|
-
case .omniSearch: return "
|
|
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 "
|
|
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 (
|
|
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
|
}
|