@lattices/cli 0.3.0 → 0.4.0
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/Package.swift +8 -1
- package/app/Sources/AdvisorLearningStore.swift +90 -0
- package/app/Sources/AgentSession.swift +377 -0
- package/app/Sources/AppDelegate.swift +44 -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 +164 -5
- 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 +733 -0
- package/app/Sources/HomeDashboardView.swift +125 -0
- package/app/Sources/HotkeyManager.swift +2 -0
- package/app/Sources/HotkeyStore.swift +45 -9
- package/app/Sources/IntentEngine.swift +925 -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 +1235 -30
- package/app/Sources/LauncherHUD.swift +348 -0
- package/app/Sources/MainView.swift +147 -44
- 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 +1 -1
- 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/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 +731 -0
- package/bin/{lattices-app.js → lattices-app.ts} +67 -32
- package/bin/lattices-dev +160 -0
- package/bin/{lattices.js → lattices.ts} +600 -137
- package/bin/project-twin.ts +645 -0
- package/docs/agent-execution-plan.md +562 -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 +21 -10
- package/bin/client.js +0 -4
|
@@ -78,6 +78,28 @@ final class ScreenMapWindowController: ObservableObject {
|
|
|
78
78
|
show()
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/// Open screen map focused on a specific window.
|
|
82
|
+
func showWindow(wid: UInt32) {
|
|
83
|
+
activePage = .screenMap
|
|
84
|
+
show()
|
|
85
|
+
|
|
86
|
+
// Avoid overlapping the voice panel — nudge screen map below it
|
|
87
|
+
if let w = window, let voicePanel = VoiceCommandWindow.shared.panel, voicePanel.isVisible {
|
|
88
|
+
let voiceBottom = voicePanel.frame.minY
|
|
89
|
+
let mapFrame = w.frame
|
|
90
|
+
if mapFrame.maxY > voiceBottom - 10 {
|
|
91
|
+
// Position just below the voice panel
|
|
92
|
+
let newY = voiceBottom - mapFrame.height - 16
|
|
93
|
+
w.setFrameOrigin(NSPoint(x: mapFrame.origin.x, y: max(newY, 40)))
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Select after a brief delay so the controller has time to populate
|
|
98
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15) { [weak self] in
|
|
99
|
+
self?.controller?.selectSingle(wid)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
81
103
|
func close() {
|
|
82
104
|
controller?.endPreview()
|
|
83
105
|
window?.orderOut(nil)
|
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
import AppKit
|
|
2
|
+
|
|
3
|
+
// MARK: - WindowRef
|
|
4
|
+
|
|
5
|
+
struct WindowRef: Codable, Identifiable {
|
|
6
|
+
let id: String
|
|
7
|
+
|
|
8
|
+
// ── Intent (stable, survives restarts) ──
|
|
9
|
+
var app: String
|
|
10
|
+
var contentHint: String?
|
|
11
|
+
var tile: String?
|
|
12
|
+
var display: Int?
|
|
13
|
+
|
|
14
|
+
// ── Runtime (ephemeral, filled when window is live) ──
|
|
15
|
+
var wid: UInt32?
|
|
16
|
+
var pid: Int32?
|
|
17
|
+
var title: String?
|
|
18
|
+
var frame: WindowFrame?
|
|
19
|
+
|
|
20
|
+
init(id: String = UUID().uuidString, app: String, contentHint: String? = nil,
|
|
21
|
+
tile: String? = nil, display: Int? = nil,
|
|
22
|
+
wid: UInt32? = nil, pid: Int32? = nil, title: String? = nil, frame: WindowFrame? = nil) {
|
|
23
|
+
self.id = id
|
|
24
|
+
self.app = app
|
|
25
|
+
self.contentHint = contentHint
|
|
26
|
+
self.tile = tile
|
|
27
|
+
self.display = display
|
|
28
|
+
self.wid = wid
|
|
29
|
+
self.pid = pid
|
|
30
|
+
self.title = title
|
|
31
|
+
self.frame = frame
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
// MARK: - SessionLayer
|
|
36
|
+
|
|
37
|
+
struct SessionLayer: Identifiable, Codable {
|
|
38
|
+
let id: String
|
|
39
|
+
var name: String
|
|
40
|
+
var windows: [WindowRef]
|
|
41
|
+
|
|
42
|
+
init(id: String = UUID().uuidString, name: String, windows: [WindowRef] = []) {
|
|
43
|
+
self.id = id
|
|
44
|
+
self.name = name
|
|
45
|
+
self.windows = windows
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// MARK: - SessionLayerStore
|
|
50
|
+
|
|
51
|
+
final class SessionLayerStore: ObservableObject {
|
|
52
|
+
static let shared = SessionLayerStore()
|
|
53
|
+
|
|
54
|
+
@Published var layers: [SessionLayer] = []
|
|
55
|
+
@Published var activeIndex: Int = -1
|
|
56
|
+
|
|
57
|
+
private init() {
|
|
58
|
+
// Listen for window changes to reconcile stale refs
|
|
59
|
+
EventBus.shared.subscribe { [weak self] event in
|
|
60
|
+
if case .windowsChanged = event {
|
|
61
|
+
DispatchQueue.main.async {
|
|
62
|
+
self?.reconcile()
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// MARK: - CRUD
|
|
69
|
+
|
|
70
|
+
@discardableResult
|
|
71
|
+
func create(name: String, windows: [WindowRef] = []) -> SessionLayer {
|
|
72
|
+
let layer = SessionLayer(name: name, windows: windows)
|
|
73
|
+
layers.append(layer)
|
|
74
|
+
DiagnosticLog.shared.info("SessionLayerStore: created '\(name)' with \(windows.count) refs")
|
|
75
|
+
// If this is the first layer, activate it
|
|
76
|
+
if layers.count == 1 { activeIndex = 0 }
|
|
77
|
+
return layer
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
func delete(id: String) {
|
|
81
|
+
guard let idx = layers.firstIndex(where: { $0.id == id }) else { return }
|
|
82
|
+
// Clear layer tags for windows in this layer
|
|
83
|
+
for ref in layers[idx].windows {
|
|
84
|
+
if let wid = ref.wid {
|
|
85
|
+
DesktopModel.shared.removeLayerTag(wid: wid)
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
layers.remove(at: idx)
|
|
89
|
+
// Adjust activeIndex
|
|
90
|
+
if layers.isEmpty {
|
|
91
|
+
activeIndex = -1
|
|
92
|
+
} else if activeIndex >= layers.count {
|
|
93
|
+
activeIndex = layers.count - 1
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
func rename(id: String, name: String) {
|
|
98
|
+
guard let idx = layers.firstIndex(where: { $0.id == id }) else { return }
|
|
99
|
+
layers[idx].name = name
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func clear() {
|
|
103
|
+
DesktopModel.shared.clearLayerTags()
|
|
104
|
+
layers.removeAll()
|
|
105
|
+
activeIndex = -1
|
|
106
|
+
LayerBezel.shared.invalidateCache()
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
func layerById(_ id: String) -> SessionLayer? {
|
|
110
|
+
layers.first { $0.id == id }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
func layerByName(_ name: String) -> SessionLayer? {
|
|
114
|
+
layers.first { $0.name.localizedCaseInsensitiveCompare(name) == .orderedSame }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// MARK: - Window Management
|
|
118
|
+
|
|
119
|
+
func assign(ref: WindowRef, toLayerId id: String) {
|
|
120
|
+
guard let idx = layers.firstIndex(where: { $0.id == id }) else { return }
|
|
121
|
+
layers[idx].windows.append(ref)
|
|
122
|
+
if let wid = ref.wid {
|
|
123
|
+
DesktopModel.shared.assignLayer(wid: wid, layerId: layers[idx].name)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
func assignByWid(_ wid: UInt32, toLayerId id: String) {
|
|
128
|
+
guard let idx = layers.firstIndex(where: { $0.id == id }) else { return }
|
|
129
|
+
guard let entry = DesktopModel.shared.windows[wid] else { return }
|
|
130
|
+
// Don't add duplicates
|
|
131
|
+
if layers[idx].windows.contains(where: { $0.wid == wid }) { return }
|
|
132
|
+
let ref = WindowRef(
|
|
133
|
+
app: entry.app,
|
|
134
|
+
contentHint: entry.title,
|
|
135
|
+
wid: entry.wid,
|
|
136
|
+
pid: entry.pid,
|
|
137
|
+
title: entry.title,
|
|
138
|
+
frame: entry.frame
|
|
139
|
+
)
|
|
140
|
+
layers[idx].windows.append(ref)
|
|
141
|
+
DesktopModel.shared.assignLayer(wid: wid, layerId: layers[idx].name)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func remove(refId: String, fromLayerId id: String) {
|
|
145
|
+
guard let idx = layers.firstIndex(where: { $0.id == id }) else { return }
|
|
146
|
+
if let refIdx = layers[idx].windows.firstIndex(where: { $0.id == refId }) {
|
|
147
|
+
if let wid = layers[idx].windows[refIdx].wid {
|
|
148
|
+
DesktopModel.shared.removeLayerTag(wid: wid)
|
|
149
|
+
}
|
|
150
|
+
layers[idx].windows.remove(at: refIdx)
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func tagFrontmostWindow() {
|
|
155
|
+
guard let frontApp = NSWorkspace.shared.frontmostApplication,
|
|
156
|
+
frontApp.bundleIdentifier != "com.arach.lattices" else { return }
|
|
157
|
+
|
|
158
|
+
let pid = frontApp.processIdentifier
|
|
159
|
+
// Find the frontmost window for this app
|
|
160
|
+
guard let entry = DesktopModel.shared.windows.values
|
|
161
|
+
.first(where: { $0.pid == pid }) else { return }
|
|
162
|
+
|
|
163
|
+
// If no layers exist, create one
|
|
164
|
+
if layers.isEmpty {
|
|
165
|
+
create(name: "Layer 1")
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// If no active layer, use first
|
|
169
|
+
let targetIndex = activeIndex >= 0 ? activeIndex : 0
|
|
170
|
+
guard targetIndex < layers.count else { return }
|
|
171
|
+
|
|
172
|
+
let layerId = layers[targetIndex].id
|
|
173
|
+
assignByWid(entry.wid, toLayerId: layerId)
|
|
174
|
+
DiagnosticLog.shared.info("SessionLayerStore: tagged \(entry.app) '\(entry.title)' → '\(layers[targetIndex].name)'")
|
|
175
|
+
|
|
176
|
+
// Show bezel feedback
|
|
177
|
+
let allNames = layers.map(\.name)
|
|
178
|
+
LayerBezel.shared.show(
|
|
179
|
+
label: layers[targetIndex].name,
|
|
180
|
+
index: targetIndex,
|
|
181
|
+
total: layers.count,
|
|
182
|
+
allLabels: allNames
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// MARK: - Switching
|
|
187
|
+
|
|
188
|
+
func switchTo(index: Int) {
|
|
189
|
+
guard index >= 0, index < layers.count else { return }
|
|
190
|
+
activeIndex = index
|
|
191
|
+
|
|
192
|
+
DesktopModel.shared.poll()
|
|
193
|
+
|
|
194
|
+
var resolved: [(wid: UInt32, pid: Int32)] = []
|
|
195
|
+
for i in layers[index].windows.indices {
|
|
196
|
+
if let r = resolve(&layers[index].windows[i]) {
|
|
197
|
+
resolved.append(r)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if !resolved.isEmpty {
|
|
202
|
+
WindowTiler.raiseWindowsAndReactivate(windows: resolved)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let allNames = layers.map(\.name)
|
|
206
|
+
LayerBezel.shared.show(
|
|
207
|
+
label: layers[index].name,
|
|
208
|
+
index: index,
|
|
209
|
+
total: layers.count,
|
|
210
|
+
allLabels: allNames
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
DiagnosticLog.shared.info("SessionLayerStore: switched to '\(layers[index].name)' (\(resolved.count)/\(layers[index].windows.count) resolved)")
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
func cycleNext() {
|
|
217
|
+
guard !layers.isEmpty else { return }
|
|
218
|
+
let next = (activeIndex + 1) % layers.count
|
|
219
|
+
switchTo(index: next)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
func cyclePrev() {
|
|
223
|
+
guard !layers.isEmpty else { return }
|
|
224
|
+
let prev = activeIndex <= 0 ? layers.count - 1 : activeIndex - 1
|
|
225
|
+
switchTo(index: prev)
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// MARK: - Resolution
|
|
229
|
+
|
|
230
|
+
private func resolve(_ ref: inout WindowRef) -> (wid: UInt32, pid: Int32)? {
|
|
231
|
+
// 1. Fast path: wid still valid
|
|
232
|
+
if let wid = ref.wid, let entry = DesktopModel.shared.windows[wid] {
|
|
233
|
+
ref.pid = entry.pid
|
|
234
|
+
ref.title = entry.title
|
|
235
|
+
ref.frame = entry.frame
|
|
236
|
+
return (wid, entry.pid)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// 2. Re-resolve by app + contentHint
|
|
240
|
+
if let entry = DesktopModel.shared.windowForApp(app: ref.app, title: ref.contentHint) {
|
|
241
|
+
ref.wid = entry.wid
|
|
242
|
+
ref.pid = entry.pid
|
|
243
|
+
ref.title = entry.title
|
|
244
|
+
ref.frame = entry.frame
|
|
245
|
+
DesktopModel.shared.assignLayer(wid: entry.wid, layerId: layerNameForRef(ref))
|
|
246
|
+
return (entry.wid, entry.pid)
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// 3. Window not found — dormant
|
|
250
|
+
ref.wid = nil
|
|
251
|
+
ref.pid = nil
|
|
252
|
+
ref.title = nil
|
|
253
|
+
ref.frame = nil
|
|
254
|
+
return nil
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
private func layerNameForRef(_ ref: WindowRef) -> String {
|
|
258
|
+
for layer in layers {
|
|
259
|
+
if layer.windows.contains(where: { $0.id == ref.id }) {
|
|
260
|
+
return layer.name
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return ""
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// MARK: - Reconciliation
|
|
267
|
+
|
|
268
|
+
func reconcile() {
|
|
269
|
+
let desktop = DesktopModel.shared
|
|
270
|
+
for layerIdx in layers.indices {
|
|
271
|
+
for refIdx in layers[layerIdx].windows.indices {
|
|
272
|
+
let ref = layers[layerIdx].windows[refIdx]
|
|
273
|
+
guard let wid = ref.wid else { continue }
|
|
274
|
+
if desktop.windows[wid] == nil {
|
|
275
|
+
// Window gone — clear runtime, keep intent
|
|
276
|
+
layers[layerIdx].windows[refIdx].wid = nil
|
|
277
|
+
layers[layerIdx].windows[refIdx].pid = nil
|
|
278
|
+
layers[layerIdx].windows[refIdx].title = nil
|
|
279
|
+
layers[layerIdx].windows[refIdx].frame = nil
|
|
280
|
+
desktop.removeLayerTag(wid: wid)
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
@@ -2,12 +2,15 @@ import AppKit
|
|
|
2
2
|
|
|
3
3
|
enum SessionManager {
|
|
4
4
|
private static let latticesPath = "/opt/homebrew/bin/lattices"
|
|
5
|
-
private static
|
|
5
|
+
private static var tmuxPath: String { TmuxQuery.resolvedPath ?? "/opt/homebrew/bin/tmux" }
|
|
6
6
|
|
|
7
7
|
/// Launch or reattach — if session is running, find and focus the existing window
|
|
8
8
|
static func launch(project: Project) {
|
|
9
9
|
let terminal = Preferences.shared.terminal
|
|
10
10
|
if project.isRunning {
|
|
11
|
+
if let window = DesktopModel.shared.windowForSession(project.sessionName) {
|
|
12
|
+
DesktopModel.shared.markInteraction(wid: window.wid)
|
|
13
|
+
}
|
|
11
14
|
terminal.focusOrAttach(session: project.sessionName)
|
|
12
15
|
} else {
|
|
13
16
|
terminal.launch(command: latticesPath, in: project.path)
|
|
@@ -71,6 +71,7 @@ struct SettingsContentView: View {
|
|
|
71
71
|
// Tab bar
|
|
72
72
|
HStack(spacing: 2) {
|
|
73
73
|
settingsTab(label: "General", id: "general")
|
|
74
|
+
settingsTab(label: "AI", id: "ai")
|
|
74
75
|
settingsTab(label: "Search & OCR", id: "search")
|
|
75
76
|
settingsTab(label: "Shortcuts", id: "shortcuts")
|
|
76
77
|
Spacer()
|
|
@@ -84,6 +85,7 @@ struct SettingsContentView: View {
|
|
|
84
85
|
switch selectedTab {
|
|
85
86
|
case "shortcuts": shortcutsContent
|
|
86
87
|
case "search": searchOcrContent
|
|
88
|
+
case "ai": aiContent
|
|
87
89
|
default: generalContent
|
|
88
90
|
}
|
|
89
91
|
}
|
|
@@ -269,6 +271,167 @@ struct SettingsContentView: View {
|
|
|
269
271
|
}
|
|
270
272
|
}
|
|
271
273
|
|
|
274
|
+
// MARK: - AI
|
|
275
|
+
|
|
276
|
+
private var aiContent: some View {
|
|
277
|
+
ScrollView {
|
|
278
|
+
VStack(spacing: 12) {
|
|
279
|
+
// ── Claude CLI ──
|
|
280
|
+
settingsCard {
|
|
281
|
+
VStack(alignment: .leading, spacing: 10) {
|
|
282
|
+
HStack(spacing: 8) {
|
|
283
|
+
Image(systemName: "sparkles")
|
|
284
|
+
.font(.system(size: 11, weight: .medium))
|
|
285
|
+
.foregroundColor(Palette.running)
|
|
286
|
+
Text("Claude CLI")
|
|
287
|
+
.font(Typo.mono(12))
|
|
288
|
+
.foregroundColor(Palette.text)
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
HStack(spacing: 6) {
|
|
292
|
+
TextField("Auto-detected", text: $prefs.claudePath)
|
|
293
|
+
.textFieldStyle(.plain)
|
|
294
|
+
.font(Typo.mono(11))
|
|
295
|
+
.foregroundColor(Palette.text)
|
|
296
|
+
.padding(.horizontal, 8)
|
|
297
|
+
.padding(.vertical, 5)
|
|
298
|
+
.background(
|
|
299
|
+
RoundedRectangle(cornerRadius: 5)
|
|
300
|
+
.fill(Color.white.opacity(0.06))
|
|
301
|
+
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5))
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
Button {
|
|
305
|
+
if let resolved = Preferences.resolveClaudePath() {
|
|
306
|
+
prefs.claudePath = resolved
|
|
307
|
+
}
|
|
308
|
+
} label: {
|
|
309
|
+
Text("Detect")
|
|
310
|
+
.font(Typo.monoBold(10))
|
|
311
|
+
.foregroundColor(Palette.text)
|
|
312
|
+
.padding(.horizontal, 10)
|
|
313
|
+
.padding(.vertical, 4)
|
|
314
|
+
.background(
|
|
315
|
+
RoundedRectangle(cornerRadius: 4)
|
|
316
|
+
.fill(Palette.surfaceHov)
|
|
317
|
+
.overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(Palette.borderLit, lineWidth: 0.5))
|
|
318
|
+
)
|
|
319
|
+
}
|
|
320
|
+
.buttonStyle(.plain)
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
let resolved = Preferences.resolveClaudePath()
|
|
324
|
+
if let path = resolved {
|
|
325
|
+
Text("Found: \(path)")
|
|
326
|
+
.font(Typo.caption(9))
|
|
327
|
+
.foregroundColor(Palette.running.opacity(0.8))
|
|
328
|
+
} else {
|
|
329
|
+
Text("Not found — install with: npm i -g @anthropic-ai/claude-code")
|
|
330
|
+
.font(Typo.caption(9))
|
|
331
|
+
.foregroundColor(Palette.detach)
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// ── Advisor ──
|
|
337
|
+
settingsCard {
|
|
338
|
+
VStack(alignment: .leading, spacing: 10) {
|
|
339
|
+
Text("Voice advisor")
|
|
340
|
+
.font(Typo.mono(11))
|
|
341
|
+
.foregroundColor(Palette.text)
|
|
342
|
+
|
|
343
|
+
HStack {
|
|
344
|
+
Text("Model")
|
|
345
|
+
.font(Typo.mono(10))
|
|
346
|
+
.foregroundColor(Palette.textDim)
|
|
347
|
+
Spacer()
|
|
348
|
+
Picker("", selection: $prefs.advisorModel) {
|
|
349
|
+
Text("Haiku").tag("haiku")
|
|
350
|
+
Text("Sonnet").tag("sonnet")
|
|
351
|
+
}
|
|
352
|
+
.pickerStyle(.segmented)
|
|
353
|
+
.labelsHidden()
|
|
354
|
+
.frame(width: 160)
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
Text("Haiku is fast and cheap. Sonnet is smarter but slower.")
|
|
358
|
+
.font(Typo.caption(9))
|
|
359
|
+
.foregroundColor(Palette.textMuted.opacity(0.7))
|
|
360
|
+
|
|
361
|
+
cardDivider
|
|
362
|
+
|
|
363
|
+
HStack {
|
|
364
|
+
Text("Budget per session")
|
|
365
|
+
.font(Typo.mono(10))
|
|
366
|
+
.foregroundColor(Palette.textDim)
|
|
367
|
+
Spacer()
|
|
368
|
+
HStack(spacing: 4) {
|
|
369
|
+
Text("$")
|
|
370
|
+
.font(Typo.mono(11))
|
|
371
|
+
.foregroundColor(Palette.textDim)
|
|
372
|
+
TextField("0.50", value: $prefs.advisorBudgetUSD, formatter: {
|
|
373
|
+
let f = NumberFormatter()
|
|
374
|
+
f.numberStyle = .decimal
|
|
375
|
+
f.minimumFractionDigits = 2
|
|
376
|
+
f.maximumFractionDigits = 2
|
|
377
|
+
return f
|
|
378
|
+
}())
|
|
379
|
+
.textFieldStyle(.plain)
|
|
380
|
+
.font(Typo.monoBold(11))
|
|
381
|
+
.foregroundColor(Palette.text)
|
|
382
|
+
.multilineTextAlignment(.center)
|
|
383
|
+
.frame(width: 50)
|
|
384
|
+
.padding(.horizontal, 4)
|
|
385
|
+
.padding(.vertical, 3)
|
|
386
|
+
.background(
|
|
387
|
+
RoundedRectangle(cornerRadius: 5)
|
|
388
|
+
.fill(Color.white.opacity(0.06))
|
|
389
|
+
.overlay(RoundedRectangle(cornerRadius: 5).strokeBorder(Color.white.opacity(0.08), lineWidth: 0.5))
|
|
390
|
+
)
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
Text("Max spend per Claude CLI invocation")
|
|
395
|
+
.font(Typo.caption(9))
|
|
396
|
+
.foregroundColor(Palette.textMuted.opacity(0.7))
|
|
397
|
+
|
|
398
|
+
cardDivider
|
|
399
|
+
|
|
400
|
+
// Session stats
|
|
401
|
+
let stats = AgentPool.shared.haiku.sessionStats
|
|
402
|
+
HStack(spacing: 12) {
|
|
403
|
+
if stats.contextWindow > 0 {
|
|
404
|
+
HStack(spacing: 4) {
|
|
405
|
+
Circle()
|
|
406
|
+
.fill(stats.contextUsage > 0.6 ? Palette.detach : Palette.running)
|
|
407
|
+
.frame(width: 5, height: 5)
|
|
408
|
+
Text("Context: \(Int(stats.contextUsage * 100))%")
|
|
409
|
+
.font(Typo.mono(10))
|
|
410
|
+
.foregroundColor(Palette.textMuted)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
if stats.costUSD > 0 {
|
|
414
|
+
Text("Session cost: $\(String(format: "%.3f", stats.costUSD))")
|
|
415
|
+
.font(Typo.mono(10))
|
|
416
|
+
.foregroundColor(Palette.textMuted)
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
Spacer()
|
|
420
|
+
|
|
421
|
+
let learningCount = AdvisorLearningStore.shared.entryCount
|
|
422
|
+
if learningCount > 0 {
|
|
423
|
+
Text("\(learningCount) learned")
|
|
424
|
+
.font(Typo.mono(9))
|
|
425
|
+
.foregroundColor(Palette.textMuted.opacity(0.6))
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
.padding(16)
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
272
435
|
// MARK: - Search & OCR
|
|
273
436
|
|
|
274
437
|
private func ocrNumField(_ value: Binding<Double>, width: CGFloat = 50) -> some View {
|
|
@@ -950,6 +1113,26 @@ struct SettingsContentView: View {
|
|
|
950
1113
|
.padding(.vertical, 12)
|
|
951
1114
|
}
|
|
952
1115
|
|
|
1116
|
+
Section(header: stickyHeader("Voice commands")) {
|
|
1117
|
+
VStack(alignment: .leading, spacing: 8) {
|
|
1118
|
+
flowStep("⌥", "Hold Option key to speak, release to stop")
|
|
1119
|
+
flowStep("⇥", "Tab to arm/disarm the mic")
|
|
1120
|
+
flowStep("⎋", "Escape to dismiss")
|
|
1121
|
+
|
|
1122
|
+
Text("Built-in commands: find, show, open, tile, kill, scan")
|
|
1123
|
+
.font(Typo.caption(10.5))
|
|
1124
|
+
.foregroundColor(Palette.textMuted)
|
|
1125
|
+
.padding(.top, 4)
|
|
1126
|
+
|
|
1127
|
+
Text("When local matching fails, Claude Haiku advises with follow-up suggestions. Configure the AI model and budget in Settings → AI.")
|
|
1128
|
+
.font(Typo.caption(10.5))
|
|
1129
|
+
.foregroundColor(Palette.textMuted)
|
|
1130
|
+
.lineSpacing(2)
|
|
1131
|
+
}
|
|
1132
|
+
.padding(.horizontal, 20)
|
|
1133
|
+
.padding(.vertical, 12)
|
|
1134
|
+
}
|
|
1135
|
+
|
|
953
1136
|
Section(header: stickyHeader("Reference")) {
|
|
954
1137
|
HStack(spacing: 8) {
|
|
955
1138
|
docsLinkButton(icon: "doc.text", label: "Config format", file: "config.md")
|
|
@@ -1015,13 +1198,13 @@ struct SettingsContentView: View {
|
|
|
1015
1198
|
}
|
|
1016
1199
|
|
|
1017
1200
|
private func resolveDocsFile(_ file: String) -> String {
|
|
1018
|
-
let devPath = "/Users/arach/dev/lattice/docs/\(file)"
|
|
1019
|
-
if FileManager.default.fileExists(atPath: devPath) { return devPath }
|
|
1020
1201
|
let bundle = Bundle.main.bundlePath
|
|
1021
1202
|
let appDir = (bundle as NSString).deletingLastPathComponent
|
|
1022
1203
|
let docsPath = ((appDir as NSString).appendingPathComponent("../docs/\(file)") as NSString).standardizingPath
|
|
1023
1204
|
if FileManager.default.fileExists(atPath: docsPath) { return docsPath }
|
|
1024
|
-
|
|
1205
|
+
// Fallback: look relative to the repo root (dev builds)
|
|
1206
|
+
let repoGuess = ((appDir as NSString).appendingPathComponent("../../docs/\(file)") as NSString).standardizingPath
|
|
1207
|
+
return FileManager.default.fileExists(atPath: repoGuess) ? repoGuess : docsPath
|
|
1025
1208
|
}
|
|
1026
1209
|
|
|
1027
1210
|
// MARK: - Shared helpers
|
package/app/Sources/Theme.swift
CHANGED
|
@@ -3,17 +3,18 @@ import SwiftUI
|
|
|
3
3
|
// MARK: - Colors
|
|
4
4
|
|
|
5
5
|
enum Palette {
|
|
6
|
-
// Base surfaces
|
|
7
|
-
static let bg = Color(red: 0.
|
|
8
|
-
static let
|
|
9
|
-
static let
|
|
10
|
-
static let
|
|
11
|
-
static let
|
|
6
|
+
// Base surfaces
|
|
7
|
+
static let bg = Color(red: 0.08, green: 0.08, blue: 0.09) // #141416
|
|
8
|
+
static let bgSidebar = Color(red: 0.08, green: 0.08, blue: 0.09) // same as bg
|
|
9
|
+
static let surface = Color(white: 0.10) // Raised cards
|
|
10
|
+
static let surfaceHov = Color(white: 0.14) // Hovered cards
|
|
11
|
+
static let border = Color.white.opacity(0.08)
|
|
12
|
+
static let borderLit = Color.white.opacity(0.14)
|
|
12
13
|
|
|
13
14
|
// Text
|
|
14
15
|
static let text = Color.white.opacity(0.92)
|
|
15
|
-
static let textDim = Color.white.opacity(0.
|
|
16
|
-
static let textMuted = Color.white.opacity(0.
|
|
16
|
+
static let textDim = Color.white.opacity(0.58)
|
|
17
|
+
static let textMuted = Color.white.opacity(0.40)
|
|
17
18
|
|
|
18
19
|
// Functional accents
|
|
19
20
|
static let running = Color(red: 0.20, green: 0.78, blue: 0.45) // Green
|
|
@@ -4,10 +4,17 @@ final class TmuxModel: ObservableObject {
|
|
|
4
4
|
static let shared = TmuxModel()
|
|
5
5
|
|
|
6
6
|
@Published private(set) var sessions: [TmuxSession] = []
|
|
7
|
+
@Published private(set) var isAvailable: Bool = TmuxQuery.isAvailable
|
|
7
8
|
private var timer: Timer?
|
|
8
9
|
|
|
9
10
|
func start(interval: TimeInterval = 3.0) {
|
|
10
11
|
guard timer == nil else { return }
|
|
12
|
+
|
|
13
|
+
if !isAvailable {
|
|
14
|
+
DiagnosticLog.shared.warn("TmuxModel: tmux not found — session features disabled")
|
|
15
|
+
return
|
|
16
|
+
}
|
|
17
|
+
|
|
11
18
|
DiagnosticLog.shared.info("TmuxModel: starting (interval=\(interval)s)")
|
|
12
19
|
poll()
|
|
13
20
|
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: true) { [weak self] _ in
|
|
@@ -23,20 +23,44 @@ struct TmuxPane: Identifiable {
|
|
|
23
23
|
// MARK: - Query
|
|
24
24
|
|
|
25
25
|
enum TmuxQuery {
|
|
26
|
-
|
|
26
|
+
/// Resolved path to the tmux binary, or nil if not found
|
|
27
|
+
static let resolvedPath: String? = {
|
|
28
|
+
let candidates = [
|
|
29
|
+
"/opt/homebrew/bin/tmux", // Apple Silicon Homebrew
|
|
30
|
+
"/usr/local/bin/tmux", // Intel Homebrew
|
|
31
|
+
"/usr/bin/tmux", // unlikely on macOS, but check
|
|
32
|
+
"/opt/local/bin/tmux", // MacPorts
|
|
33
|
+
]
|
|
34
|
+
for path in candidates {
|
|
35
|
+
if FileManager.default.isExecutableFile(atPath: path) {
|
|
36
|
+
return path
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
// Fall back to PATH lookup via /usr/bin/which
|
|
40
|
+
let result = ProcessQuery.shell(["/usr/bin/which", "tmux"]).trimmingCharacters(in: .whitespacesAndNewlines)
|
|
41
|
+
if !result.isEmpty && FileManager.default.isExecutableFile(atPath: result) {
|
|
42
|
+
return result
|
|
43
|
+
}
|
|
44
|
+
return nil
|
|
45
|
+
}()
|
|
46
|
+
|
|
47
|
+
/// Whether tmux is available on this system
|
|
48
|
+
static var isAvailable: Bool { resolvedPath != nil }
|
|
27
49
|
|
|
28
50
|
/// List all tmux sessions with their panes in exactly 2 shell calls
|
|
29
51
|
static func listSessions() -> [TmuxSession] {
|
|
52
|
+
guard let tmux = resolvedPath else { return [] }
|
|
53
|
+
|
|
30
54
|
// Query 1: all sessions
|
|
31
55
|
let sessionsRaw = shell([
|
|
32
|
-
|
|
56
|
+
tmux, "list-sessions", "-F",
|
|
33
57
|
"#{session_name}\t#{session_windows}\t#{session_created}\t#{session_attached}"
|
|
34
58
|
])
|
|
35
59
|
guard !sessionsRaw.isEmpty else { return [] }
|
|
36
60
|
|
|
37
61
|
// Query 2: all panes across all sessions
|
|
38
62
|
let panesRaw = shell([
|
|
39
|
-
|
|
63
|
+
tmux, "list-panes", "-a", "-F",
|
|
40
64
|
"#{session_name}\t#{window_index}\t#{window_name}\t#{pane_id}\t#{pane_title}\t#{pane_current_command}\t#{pane_pid}\t#{pane_active}"
|
|
41
65
|
])
|
|
42
66
|
|