codex-flow 2.1.13__py3-none-any.whl
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.
- codex_flow/__init__.py +28 -0
- codex_flow/__main__.py +9 -0
- codex_flow/cli.py +242 -0
- codex_flow/data/LICENSE +21 -0
- codex_flow/data/README.en.md +303 -0
- codex_flow/data/README.md +305 -0
- codex_flow/data/VERSION +1 -0
- codex_flow/data/apps/chatgpt-mcp/README.md +86 -0
- codex_flow/data/apps/chatgpt-mcp/__init__.py +1 -0
- codex_flow/data/apps/chatgpt-mcp/adapter.py +458 -0
- codex_flow/data/apps/chatgpt-mcp/server.py +358 -0
- codex_flow/data/apps/chatgpt-mcp/widget.html +927 -0
- codex_flow/data/apps/macos-overlay/README.en.md +121 -0
- codex_flow/data/apps/macos-overlay/README.md +123 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayRuntimeState.swift +126 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayScreenGeometry.swift +82 -0
- codex_flow/data/apps/macos-overlay/Sources/Controllers/OverlayWindowController.swift +1052 -0
- codex_flow/data/apps/macos-overlay/Sources/Localization.swift +197 -0
- codex_flow/data/apps/macos-overlay/Sources/Models/TelemetryData.swift +1557 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/AccountSnapshotService.swift +1101 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/FlowPilotInstanceLock.swift +153 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/IPCServer.swift +298 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryQueryEngine.swift +800 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/TelemetryWatcher.swift +135 -0
- codex_flow/data/apps/macos-overlay/Sources/Services/UpdateService.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AccountView.swift +610 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AnalyticsView.swift +566 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/AutostartView.swift +293 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/BubbleView.swift +317 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HistoryView.swift +1124 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/HoverRevealText.swift +165 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/InspectorSkillsToolsView.swift +121 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/LogoView.swift +182 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SleekSwitch.swift +117 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/StrategyModeView.swift +561 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/SummaryView.swift +1273 -0
- codex_flow/data/apps/macos-overlay/Sources/Views/UpdateView.swift +352 -0
- codex_flow/data/apps/macos-overlay/Sources/main.swift +340 -0
- codex_flow/data/apps/macos-overlay/Tests/OverlayScreenGeometryTests.swift +163 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryPhase1ContractTests.swift +357 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQueryEngineConcurrencyTests.swift +221 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryQuotaSelectionTests.swift +158 -0
- codex_flow/data/apps/macos-overlay/Tests/TelemetryWorkerTokenTests.swift +122 -0
- codex_flow/data/apps/macos-overlay/build.sh +75 -0
- codex_flow/data/benchmark/corpus.json +103 -0
- codex_flow/data/benchmark/manifest.example.json +41 -0
- codex_flow/data/benchmark/manifest.schema.json +137 -0
- codex_flow/data/benchmark/prices/gpt-5.6-2026-08-30.json +5 -0
- codex_flow/data/benchmark/profiles.json +90 -0
- codex_flow/data/benchmark/schema.json +77 -0
- codex_flow/data/benchmark/tasks.json +50 -0
- codex_flow/data/completions/codex-flow.bash +34 -0
- codex_flow/data/completions/codex-flow.zsh +52 -0
- codex_flow/data/glama.json +6 -0
- codex_flow/data/install-release.ps1 +126 -0
- codex_flow/data/install-release.sh +155 -0
- codex_flow/data/install.ps1 +349 -0
- codex_flow/data/install.sh +362 -0
- codex_flow/data/policy/benchmark.toml +49 -0
- codex_flow/data/policy/defaults.toml +70 -0
- codex_flow/data/scripts/analyze-benchmark.py +510 -0
- codex_flow/data/scripts/benchmark-local.py +171 -0
- codex_flow/data/scripts/check-recommendation.py +277 -0
- codex_flow/data/scripts/doctor.py +449 -0
- codex_flow/data/scripts/generate-release-manifest.py +74 -0
- codex_flow/data/scripts/localization.py +192 -0
- codex_flow/data/scripts/manage-hooks.py +448 -0
- codex_flow/data/scripts/manage-instructions.py +389 -0
- codex_flow/data/scripts/manage-shell.py +151 -0
- codex_flow/data/scripts/materialize-corpus.py +193 -0
- codex_flow/data/scripts/menu.py +646 -0
- codex_flow/data/scripts/migrations/0001_update_settings.py +80 -0
- codex_flow/data/scripts/package-release.py +132 -0
- codex_flow/data/scripts/render-benchmark-report.py +292 -0
- codex_flow/data/scripts/run-benchmark.py +829 -0
- codex_flow/data/scripts/strategies/__init__.py +28 -0
- codex_flow/data/scripts/strategies/balanced.py +115 -0
- codex_flow/data/scripts/strategies/base.py +363 -0
- codex_flow/data/scripts/strategies/efficient.py +158 -0
- codex_flow/data/scripts/strategies/lifecycle_runtime.py +590 -0
- codex_flow/data/scripts/strategies/quality.py +209 -0
- codex_flow/data/scripts/strategies/speed.py +108 -0
- codex_flow/data/scripts/strategies/task_budget_runtime.py +644 -0
- codex_flow/data/scripts/strategies/task_phase_runtime.py +341 -0
- codex_flow/data/scripts/strategies/work_unit_runtime.py +421 -0
- codex_flow/data/scripts/strategy_runtime.py +1091 -0
- codex_flow/data/scripts/telemetry.py +400 -0
- codex_flow/data/scripts/telemetry_core/__init__.py +192 -0
- codex_flow/data/scripts/telemetry_core/app_server.py +1192 -0
- codex_flow/data/scripts/telemetry_core/collector.py +1247 -0
- codex_flow/data/scripts/telemetry_core/common.py +421 -0
- codex_flow/data/scripts/telemetry_core/latency.py +593 -0
- codex_flow/data/scripts/telemetry_core/query.py +427 -0
- codex_flow/data/scripts/telemetry_core/quota_ledger.py +598 -0
- codex_flow/data/scripts/telemetry_core/render.py +460 -0
- codex_flow/data/scripts/telemetry_core/repair.py +223 -0
- codex_flow/data/scripts/ui.py +266 -0
- codex_flow/data/scripts/update-homebrew-formula.py +146 -0
- codex_flow/data/scripts/update_runtime_config.py +134 -0
- codex_flow/data/scripts/updater.py +1718 -0
- codex_flow/data/smithery.yaml +18 -0
- codex_flow/data/templates/agents/worker-explorer.toml +24 -0
- codex_flow/data/templates/agents/worker-implementer.toml +49 -0
- codex_flow/data/templates/agents/worker-reviewer.toml +25 -0
- codex_flow/data/templates/flow-pilot-instructions.md +35 -0
- codex_flow/data/templates/skills/flow-pilot/SKILL.md +577 -0
- codex_flow/mcp.py +35 -0
- codex_flow-2.1.13.dist-info/METADATA +342 -0
- codex_flow-2.1.13.dist-info/RECORD +113 -0
- codex_flow-2.1.13.dist-info/WHEEL +5 -0
- codex_flow-2.1.13.dist-info/entry_points.txt +3 -0
- codex_flow-2.1.13.dist-info/licenses/LICENSE +21 -0
- codex_flow-2.1.13.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,1052 @@
|
|
|
1
|
+
import Cocoa
|
|
2
|
+
import SwiftUI
|
|
3
|
+
import Combine
|
|
4
|
+
|
|
5
|
+
public enum DockEdge: String, Codable {
|
|
6
|
+
case none
|
|
7
|
+
case left
|
|
8
|
+
case right
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// MARK: - Shared Observable State
|
|
12
|
+
public class OverlayState: ObservableObject {
|
|
13
|
+
@Published public var isExpanded: Bool = false
|
|
14
|
+
@Published public var isPinned: Bool = false
|
|
15
|
+
@Published public var isTaskRunning: Bool = false
|
|
16
|
+
@Published public var isDocked: Bool = false
|
|
17
|
+
@Published public var dockEdge: DockEdge = .right
|
|
18
|
+
@Published public var latestRun: TaskRun? = nil
|
|
19
|
+
@Published public var isPrivacyMode: Bool = false
|
|
20
|
+
|
|
21
|
+
@Published public var activeTab: OverlayTab = .inspector
|
|
22
|
+
@Published public var inspectedRun: TaskRun? = nil
|
|
23
|
+
@Published public var historyRuns: [TaskRun] = []
|
|
24
|
+
@Published public var historyChats: [ChatSession] = []
|
|
25
|
+
@Published public var expandedChatIds: Set<String> = []
|
|
26
|
+
@Published public var statsData: TelemetryStats? = nil
|
|
27
|
+
@Published public var statsDays: Int = 30
|
|
28
|
+
@Published public var selectedProject: String? = nil
|
|
29
|
+
@Published public var isTodayOnly: Bool = false
|
|
30
|
+
@Published public var searchQuery: String = ""
|
|
31
|
+
@Published public var recentChats: [ChatSession] = []
|
|
32
|
+
@Published public var allProjectsList: [String] = []
|
|
33
|
+
|
|
34
|
+
public weak var windowController: OverlayWindowController?
|
|
35
|
+
|
|
36
|
+
private var historyLoadGeneration = 0
|
|
37
|
+
private var statsLoadGeneration = 0
|
|
38
|
+
|
|
39
|
+
public init() {
|
|
40
|
+
if let latest = TelemetryQueryEngine.shared.loadLatestRun() {
|
|
41
|
+
latestRun = latest
|
|
42
|
+
isTaskRunning = latest.isRunning
|
|
43
|
+
}
|
|
44
|
+
loadMenuData()
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
public func loadMenuData() {
|
|
48
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
49
|
+
let chats = TelemetryQueryEngine.shared.fetchChatHistory(limit: 15)
|
|
50
|
+
let projects = TelemetryQueryEngine.shared.allProjects()
|
|
51
|
+
DispatchQueue.main.async {
|
|
52
|
+
self.recentChats = chats
|
|
53
|
+
self.allProjectsList = projects
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
public func expand() {
|
|
59
|
+
guard !isExpanded else { return }
|
|
60
|
+
loadMenuData()
|
|
61
|
+
DispatchQueue.main.async {
|
|
62
|
+
self.windowController?.prepareForPresentationChange()
|
|
63
|
+
self.isDocked = false
|
|
64
|
+
self.isExpanded = true
|
|
65
|
+
self.windowController?.updateWindowFrame(animated: true)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
public func collapse() {
|
|
70
|
+
guard isExpanded else { return }
|
|
71
|
+
DispatchQueue.main.async {
|
|
72
|
+
self.windowController?.prepareForPresentationChange()
|
|
73
|
+
self.isExpanded = false
|
|
74
|
+
self.isPinned = false
|
|
75
|
+
self.isDocked = false
|
|
76
|
+
// Reliability first: AppKit frame interpolation while SwiftUI swaps
|
|
77
|
+
// a 384x490 panel for a 76x76 bubble has been the source of several
|
|
78
|
+
// display/tracking races. Collapse now commits one stable frame;
|
|
79
|
+
// the compact circle/pill still animates entirely inside that host.
|
|
80
|
+
self.windowController?.updateWindowFrame(animated: false)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
public func toggle() {
|
|
85
|
+
isExpanded ? collapse() : expand()
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
public func selectTab(_ tab: OverlayTab) {
|
|
89
|
+
DispatchQueue.main.async {
|
|
90
|
+
self.activeTab = tab
|
|
91
|
+
if tab == .history {
|
|
92
|
+
self.loadHistory()
|
|
93
|
+
} else if tab == .analytics {
|
|
94
|
+
self.loadStats()
|
|
95
|
+
}
|
|
96
|
+
self.windowController?.updateWindowFrame(animated: true)
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
public func inspect(run: TaskRun) {
|
|
101
|
+
var enrichedRun = run
|
|
102
|
+
DispatchQueue.main.async {
|
|
103
|
+
self.inspectedRun = enrichedRun
|
|
104
|
+
self.activeTab = .inspector
|
|
105
|
+
self.windowController?.updateWindowFrame(animated: true)
|
|
106
|
+
}
|
|
107
|
+
if enrichedRun.trajectory == nil || enrichedRun.skillsUsed == nil || enrichedRun.toolsUsed == nil || enrichedRun.logs == nil {
|
|
108
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
109
|
+
TelemetryQueryEngine.shared.enrichRunIfNeeded(&enrichedRun)
|
|
110
|
+
DispatchQueue.main.async {
|
|
111
|
+
if self.inspectedRun?.id == enrichedRun.id {
|
|
112
|
+
self.inspectedRun = enrichedRun
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
public func jumpToLive() {
|
|
120
|
+
DispatchQueue.main.async {
|
|
121
|
+
self.inspectedRun = nil
|
|
122
|
+
self.activeTab = .inspector
|
|
123
|
+
self.windowController?.updateWindowFrame(animated: true)
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
public func toggleChatExpansion(_ id: String) {
|
|
128
|
+
if expandedChatIds.contains(id) {
|
|
129
|
+
expandedChatIds.remove(id)
|
|
130
|
+
} else {
|
|
131
|
+
expandedChatIds.insert(id)
|
|
132
|
+
}
|
|
133
|
+
windowController?.updateWindowFrame(animated: true)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
public func isChatExpanded(_ id: String) -> Bool {
|
|
137
|
+
expandedChatIds.contains(id)
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
public func expandAllChats() {
|
|
141
|
+
expandedChatIds = Set(historyChats.map { $0.sessionId })
|
|
142
|
+
windowController?.updateWindowFrame(animated: true)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
public func collapseAllChats() {
|
|
146
|
+
expandedChatIds.removeAll()
|
|
147
|
+
windowController?.updateWindowFrame(animated: true)
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
public func loadHistory() {
|
|
151
|
+
guard Thread.isMainThread else {
|
|
152
|
+
DispatchQueue.main.async { self.loadHistory() }
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
historyLoadGeneration += 1
|
|
157
|
+
let generation = historyLoadGeneration
|
|
158
|
+
let project = selectedProject
|
|
159
|
+
let todayOnly = isTodayOnly
|
|
160
|
+
let search = searchQuery
|
|
161
|
+
|
|
162
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
163
|
+
let chats = TelemetryQueryEngine.shared.fetchChatHistory(
|
|
164
|
+
limit: 60,
|
|
165
|
+
project: project,
|
|
166
|
+
todayOnly: todayOnly,
|
|
167
|
+
search: search
|
|
168
|
+
)
|
|
169
|
+
let runs = TelemetryQueryEngine.shared.fetchHistory(
|
|
170
|
+
limit: 60,
|
|
171
|
+
project: project,
|
|
172
|
+
todayOnly: todayOnly,
|
|
173
|
+
search: search
|
|
174
|
+
)
|
|
175
|
+
DispatchQueue.main.async {
|
|
176
|
+
guard generation == self.historyLoadGeneration else { return }
|
|
177
|
+
self.historyChats = chats
|
|
178
|
+
self.historyRuns = runs
|
|
179
|
+
if self.expandedChatIds.isEmpty, let first = chats.first {
|
|
180
|
+
self.expandedChatIds.insert(first.sessionId)
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
public func loadStats() {
|
|
187
|
+
guard Thread.isMainThread else {
|
|
188
|
+
DispatchQueue.main.async { self.loadStats() }
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
statsLoadGeneration += 1
|
|
193
|
+
let generation = statsLoadGeneration
|
|
194
|
+
let days = statsDays
|
|
195
|
+
let project = selectedProject
|
|
196
|
+
|
|
197
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
198
|
+
let stats = TelemetryQueryEngine.shared.computeStats(days: days, project: project)
|
|
199
|
+
DispatchQueue.main.async {
|
|
200
|
+
guard generation == self.statsLoadGeneration else { return }
|
|
201
|
+
self.statsData = stats
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
public func update(run: TaskRun) {
|
|
207
|
+
DispatchQueue.main.async {
|
|
208
|
+
self.latestRun = run
|
|
209
|
+
self.isTaskRunning = run.isRunning
|
|
210
|
+
if self.activeTab == .history {
|
|
211
|
+
self.loadHistory()
|
|
212
|
+
} else if self.activeTab == .analytics {
|
|
213
|
+
self.loadStats()
|
|
214
|
+
}
|
|
215
|
+
if self.isExpanded {
|
|
216
|
+
self.windowController?.updateWindowFrame(animated: true)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// MARK: - Main Root Container View
|
|
223
|
+
public struct OverlayRootView: View {
|
|
224
|
+
@ObservedObject var state: OverlayState
|
|
225
|
+
|
|
226
|
+
public init(state: OverlayState) {
|
|
227
|
+
self.state = state
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
public var body: some View {
|
|
231
|
+
ZStack(alignment: .topTrailing) {
|
|
232
|
+
if state.isExpanded {
|
|
233
|
+
SummaryView(state: state)
|
|
234
|
+
.frame(width: 384)
|
|
235
|
+
.transition(
|
|
236
|
+
.asymmetric(
|
|
237
|
+
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .topTrailing)),
|
|
238
|
+
removal: .opacity.combined(with: .scale(scale: 0.95, anchor: .topTrailing))
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
} else {
|
|
242
|
+
BubbleView(state: state)
|
|
243
|
+
.frame(width: 76, height: 76)
|
|
244
|
+
.transition(
|
|
245
|
+
.asymmetric(
|
|
246
|
+
insertion: .opacity.combined(with: .scale(scale: 0.95, anchor: .topTrailing)),
|
|
247
|
+
removal: .opacity.combined(with: .scale(scale: 0.95, anchor: .topTrailing))
|
|
248
|
+
)
|
|
249
|
+
)
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing)
|
|
253
|
+
.background(Color.clear)
|
|
254
|
+
.animation(.spring(response: 0.16, dampingFraction: 0.82), value: state.isExpanded)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// MARK: - Custom Tracking Hosting View
|
|
259
|
+
class TrackingHostingView<Content: View>: NSHostingView<Content> {
|
|
260
|
+
weak var windowController: OverlayWindowController?
|
|
261
|
+
private var trackingArea: NSTrackingArea?
|
|
262
|
+
private var logicalPointerInside = false
|
|
263
|
+
private var initialMouseScreenLocation: NSPoint = .zero
|
|
264
|
+
private var initialWindowOrigin: NSPoint = .zero
|
|
265
|
+
private var isDragging = false
|
|
266
|
+
private var ownsPointerInteraction = false
|
|
267
|
+
|
|
268
|
+
required public init(rootView: Content) {
|
|
269
|
+
super.init(rootView: rootView)
|
|
270
|
+
wantsLayer = true
|
|
271
|
+
layer?.backgroundColor = NSColor.clear.cgColor
|
|
272
|
+
layer?.isOpaque = false
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
required init?(coder: NSCoder) {
|
|
276
|
+
super.init(coder: coder)
|
|
277
|
+
wantsLayer = true
|
|
278
|
+
layer?.backgroundColor = NSColor.clear.cgColor
|
|
279
|
+
layer?.isOpaque = false
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
override func updateTrackingAreas() {
|
|
283
|
+
super.updateTrackingAreas()
|
|
284
|
+
if let trackingArea {
|
|
285
|
+
removeTrackingArea(trackingArea)
|
|
286
|
+
}
|
|
287
|
+
let options: NSTrackingArea.Options = [
|
|
288
|
+
.mouseEnteredAndExited,
|
|
289
|
+
.mouseMoved,
|
|
290
|
+
.activeAlways,
|
|
291
|
+
.inVisibleRect
|
|
292
|
+
]
|
|
293
|
+
let area = NSTrackingArea(rect: bounds, options: options, owner: self, userInfo: nil)
|
|
294
|
+
addTrackingArea(area)
|
|
295
|
+
trackingArea = area
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
override func hitTest(_ point: NSPoint) -> NSView? {
|
|
299
|
+
guard let windowController else { return super.hitTest(point) }
|
|
300
|
+
guard windowController.isPointerInteractive(at: point, in: bounds) else { return nil }
|
|
301
|
+
return super.hitTest(point)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private func localPoint(for event: NSEvent) -> NSPoint {
|
|
305
|
+
convert(event.locationInWindow, from: nil)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private func routePointerMotion(_ event: NSEvent) {
|
|
309
|
+
guard let windowController else { return }
|
|
310
|
+
let point = localPoint(for: event)
|
|
311
|
+
let isInside = windowController.isPointerInteractive(at: point, in: bounds)
|
|
312
|
+
|
|
313
|
+
if isInside {
|
|
314
|
+
if logicalPointerInside {
|
|
315
|
+
windowController.handleMouseMoved(at: point)
|
|
316
|
+
} else {
|
|
317
|
+
logicalPointerInside = true
|
|
318
|
+
windowController.handleMouseEntered(at: point)
|
|
319
|
+
}
|
|
320
|
+
} else if logicalPointerInside {
|
|
321
|
+
logicalPointerInside = false
|
|
322
|
+
windowController.handleMouseExited()
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
override func mouseEntered(with event: NSEvent) {
|
|
327
|
+
super.mouseEntered(with: event)
|
|
328
|
+
routePointerMotion(event)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
override func mouseMoved(with event: NSEvent) {
|
|
332
|
+
super.mouseMoved(with: event)
|
|
333
|
+
routePointerMotion(event)
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
override func mouseExited(with event: NSEvent) {
|
|
337
|
+
super.mouseExited(with: event)
|
|
338
|
+
if logicalPointerInside {
|
|
339
|
+
logicalPointerInside = false
|
|
340
|
+
windowController?.handleMouseExited()
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// MARK: - Dragging & Magnetic Edge Snapping
|
|
345
|
+
override func mouseDown(with event: NSEvent) {
|
|
346
|
+
guard let window,
|
|
347
|
+
let windowController,
|
|
348
|
+
windowController.isPointerInteractive(at: localPoint(for: event), in: bounds),
|
|
349
|
+
windowController.beginPointerInteraction() else { return }
|
|
350
|
+
|
|
351
|
+
ownsPointerInteraction = true
|
|
352
|
+
initialMouseScreenLocation = NSEvent.mouseLocation
|
|
353
|
+
initialWindowOrigin = window.frame.origin
|
|
354
|
+
isDragging = false
|
|
355
|
+
windowController.cancelDwellTimer()
|
|
356
|
+
windowController.cancelTuckTimer()
|
|
357
|
+
super.mouseDown(with: event)
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
override func mouseDragged(with event: NSEvent) {
|
|
361
|
+
guard ownsPointerInteraction,
|
|
362
|
+
let window,
|
|
363
|
+
let windowController else {
|
|
364
|
+
super.mouseDragged(with: event)
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
let currentMouseScreenLocation = NSEvent.mouseLocation
|
|
369
|
+
let deltaX = currentMouseScreenLocation.x - initialMouseScreenLocation.x
|
|
370
|
+
let deltaY = currentMouseScreenLocation.y - initialMouseScreenLocation.y
|
|
371
|
+
let dragThreshold: CGFloat = windowController.state.isExpanded ? 8.0 : 4.0
|
|
372
|
+
|
|
373
|
+
if isDragging || abs(deltaX) > dragThreshold || abs(deltaY) > dragThreshold {
|
|
374
|
+
isDragging = true
|
|
375
|
+
windowController.cancelDwellTimer()
|
|
376
|
+
windowController.cancelTuckTimer()
|
|
377
|
+
|
|
378
|
+
var newOrigin = NSPoint(
|
|
379
|
+
x: initialWindowOrigin.x + deltaX,
|
|
380
|
+
y: initialWindowOrigin.y + deltaY
|
|
381
|
+
)
|
|
382
|
+
|
|
383
|
+
if let visible = windowController.visibleFrameForDrag(
|
|
384
|
+
mouseLocation: currentMouseScreenLocation,
|
|
385
|
+
windowFrame: window.frame
|
|
386
|
+
) {
|
|
387
|
+
newOrigin = OverlayScreenGeometry.clamp(
|
|
388
|
+
newOrigin,
|
|
389
|
+
windowSize: window.frame.size,
|
|
390
|
+
to: visible
|
|
391
|
+
)
|
|
392
|
+
}
|
|
393
|
+
window.setFrameOrigin(newOrigin)
|
|
394
|
+
} else {
|
|
395
|
+
super.mouseDragged(with: event)
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
override func mouseUp(with event: NSEvent) {
|
|
400
|
+
guard ownsPointerInteraction else {
|
|
401
|
+
super.mouseUp(with: event)
|
|
402
|
+
return
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
ownsPointerInteraction = false
|
|
406
|
+
let dragged = isDragging
|
|
407
|
+
isDragging = false
|
|
408
|
+
|
|
409
|
+
if dragged, let window, let windowController {
|
|
410
|
+
windowController.endPointerInteraction(drainPendingPresentation: false)
|
|
411
|
+
windowController.performMagneticSnap(
|
|
412
|
+
for: window,
|
|
413
|
+
pointerLocation: NSEvent.mouseLocation
|
|
414
|
+
)
|
|
415
|
+
} else if let windowController {
|
|
416
|
+
windowController.endPointerInteraction(drainPendingPresentation: true)
|
|
417
|
+
if !windowController.state.isExpanded {
|
|
418
|
+
windowController.state.expand()
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
super.mouseUp(with: event)
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
override func rightMouseDown(with event: NSEvent) {
|
|
425
|
+
guard let windowController,
|
|
426
|
+
windowController.isPointerInteractive(at: localPoint(for: event), in: bounds) else { return }
|
|
427
|
+
|
|
428
|
+
let menu = NSMenu()
|
|
429
|
+
let state = windowController.state
|
|
430
|
+
if state.isExpanded {
|
|
431
|
+
let pinItem = NSMenuItem(
|
|
432
|
+
title: state.isPinned ? L("Unpin Window", "取消置顶") : L("Pin Window", "置顶窗口"),
|
|
433
|
+
action: #selector(togglePin),
|
|
434
|
+
keyEquivalent: "p"
|
|
435
|
+
)
|
|
436
|
+
pinItem.target = self
|
|
437
|
+
menu.addItem(pinItem)
|
|
438
|
+
|
|
439
|
+
let collapseItem = NSMenuItem(
|
|
440
|
+
title: L("Collapse to Bubble", "收起为悬浮球"),
|
|
441
|
+
action: #selector(collapseBubble),
|
|
442
|
+
keyEquivalent: "c"
|
|
443
|
+
)
|
|
444
|
+
collapseItem.target = self
|
|
445
|
+
menu.addItem(collapseItem)
|
|
446
|
+
} else {
|
|
447
|
+
let expandItem = NSMenuItem(
|
|
448
|
+
title: L("Expand Summary", "展开摘要"),
|
|
449
|
+
action: #selector(expandSummary),
|
|
450
|
+
keyEquivalent: "e"
|
|
451
|
+
)
|
|
452
|
+
expandItem.target = self
|
|
453
|
+
menu.addItem(expandItem)
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
menu.addItem(NSMenuItem.separator())
|
|
457
|
+
|
|
458
|
+
let consoleItem = NSMenuItem(
|
|
459
|
+
title: L("Open FlowPilot Console", "打开 FlowPilot 控制台"),
|
|
460
|
+
action: #selector(openConsole),
|
|
461
|
+
keyEquivalent: "t"
|
|
462
|
+
)
|
|
463
|
+
consoleItem.target = self
|
|
464
|
+
menu.addItem(consoleItem)
|
|
465
|
+
|
|
466
|
+
let refreshItem = NSMenuItem(
|
|
467
|
+
title: L("Refresh Telemetry", "刷新遥测数据"),
|
|
468
|
+
action: #selector(refreshData),
|
|
469
|
+
keyEquivalent: "r"
|
|
470
|
+
)
|
|
471
|
+
refreshItem.target = self
|
|
472
|
+
menu.addItem(refreshItem)
|
|
473
|
+
|
|
474
|
+
menu.addItem(NSMenuItem.separator())
|
|
475
|
+
|
|
476
|
+
let quitItem = NSMenuItem(
|
|
477
|
+
title: L("Quit FlowPilot", "退出 FlowPilot"),
|
|
478
|
+
action: #selector(quitApp),
|
|
479
|
+
keyEquivalent: "q"
|
|
480
|
+
)
|
|
481
|
+
quitItem.target = self
|
|
482
|
+
menu.addItem(quitItem)
|
|
483
|
+
|
|
484
|
+
NSMenu.popUpContextMenu(menu, with: event, for: self)
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
@objc private func togglePin() {
|
|
488
|
+
windowController?.state.isPinned.toggle()
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
@objc private func expandSummary() {
|
|
492
|
+
windowController?.state.expand()
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
@objc private func collapseBubble() {
|
|
496
|
+
windowController?.state.collapse()
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
@objc private func openConsole() {
|
|
500
|
+
let script = """
|
|
501
|
+
tell application "Terminal"
|
|
502
|
+
activate
|
|
503
|
+
do script "codex-flow"
|
|
504
|
+
end tell
|
|
505
|
+
"""
|
|
506
|
+
if let appleScript = NSAppleScript(source: script) {
|
|
507
|
+
var error: NSDictionary?
|
|
508
|
+
appleScript.executeAndReturnError(&error)
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
@objc private func refreshData() {
|
|
513
|
+
windowController?.refreshTelemetry()
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
@objc private func quitApp() {
|
|
517
|
+
NSApplication.shared.terminate(nil)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// MARK: - Overlay Window Controller
|
|
522
|
+
public class OverlayWindowController: NSObject, NSWindowDelegate {
|
|
523
|
+
public let state: OverlayState
|
|
524
|
+
public var window: NSPanel!
|
|
525
|
+
|
|
526
|
+
public var isInteractingOrDragging: Bool {
|
|
527
|
+
runtime.pointerInteractionActive
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
private var hoverDwellTimer: Timer?
|
|
531
|
+
private var collapseTimer: Timer?
|
|
532
|
+
private var tuckTimer: Timer?
|
|
533
|
+
private let edgeTuckIdleInterval: TimeInterval = 30.0
|
|
534
|
+
|
|
535
|
+
private var runtime = OverlayRuntimeState()
|
|
536
|
+
private var hoverGate = OverlayHoverGate(rearmDistance: 6)
|
|
537
|
+
private var pendingPresentationAnimated = true
|
|
538
|
+
private var needsPointerReconciliationAfterGeometry = false
|
|
539
|
+
|
|
540
|
+
private let bubbleSize = NSSize(width: 76, height: 76)
|
|
541
|
+
private let summarySize = NSSize(width: 384, height: 490)
|
|
542
|
+
private let snapMargin: CGFloat = 8.0
|
|
543
|
+
private let snapThreshold: CGFloat = 36.0
|
|
544
|
+
|
|
545
|
+
public init(state: OverlayState) {
|
|
546
|
+
self.state = state
|
|
547
|
+
super.init()
|
|
548
|
+
state.windowController = self
|
|
549
|
+
setupWindow()
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
private var visibleFrames: [NSRect] {
|
|
553
|
+
NSScreen.screens.map { $0.visibleFrame }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private var isGeometryTransitioning: Bool {
|
|
557
|
+
runtime.isGeometryTransitioning
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
private func resolvedVisibleFrame(for frame: NSRect, preferredPoint: NSPoint? = nil) -> NSRect? {
|
|
561
|
+
let frames = visibleFrames
|
|
562
|
+
if let preferredPoint,
|
|
563
|
+
let byPointer = OverlayScreenGeometry.visibleFrame(containing: preferredPoint, among: frames) {
|
|
564
|
+
return byPointer
|
|
565
|
+
}
|
|
566
|
+
if let byFrame = OverlayScreenGeometry.bestVisibleFrame(for: frame, among: frames) {
|
|
567
|
+
return byFrame
|
|
568
|
+
}
|
|
569
|
+
return NSScreen.main?.visibleFrame
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
private func presentationVisibleFrame(for frame: NSRect) -> NSRect? {
|
|
573
|
+
OverlayScreenGeometry.presentationVisibleFrame(for: frame, among: visibleFrames)
|
|
574
|
+
?? NSScreen.main?.visibleFrame
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
func visibleFrameForDrag(mouseLocation: NSPoint, windowFrame: NSRect) -> NSRect? {
|
|
578
|
+
resolvedVisibleFrame(for: windowFrame, preferredPoint: mouseLocation)
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
private func setupWindow() {
|
|
582
|
+
state.dockEdge = .right
|
|
583
|
+
let restored = loadSavedPosition() ?? defaultPosition(for: bubbleSize)
|
|
584
|
+
let initialRect = normalizedCollapsedFrame(restored)
|
|
585
|
+
|
|
586
|
+
window = NSPanel(
|
|
587
|
+
contentRect: initialRect,
|
|
588
|
+
styleMask: [.borderless, .nonactivatingPanel],
|
|
589
|
+
backing: .buffered,
|
|
590
|
+
defer: false
|
|
591
|
+
)
|
|
592
|
+
window.level = .floating
|
|
593
|
+
window.isFloatingPanel = true
|
|
594
|
+
window.isOpaque = false
|
|
595
|
+
window.backgroundColor = .clear
|
|
596
|
+
window.hasShadow = false
|
|
597
|
+
window.hidesOnDeactivate = false
|
|
598
|
+
window.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary]
|
|
599
|
+
window.isMovableByWindowBackground = false
|
|
600
|
+
window.delegate = self
|
|
601
|
+
|
|
602
|
+
let hostingView = TrackingHostingView(rootView: OverlayRootView(state: state))
|
|
603
|
+
hostingView.windowController = self
|
|
604
|
+
window.contentView = hostingView
|
|
605
|
+
window.orderFrontRegardless()
|
|
606
|
+
|
|
607
|
+
DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { [weak self] in
|
|
608
|
+
self?.scheduleTuck()
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
public func prepareForPresentationChange() {
|
|
613
|
+
cancelDwellTimer()
|
|
614
|
+
cancelTuckTimer()
|
|
615
|
+
collapseTimer?.invalidate()
|
|
616
|
+
collapseTimer = nil
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// MARK: - Pointer ownership / hit testing
|
|
620
|
+
func isPointerInteractive(at point: NSPoint, in hostBounds: NSRect) -> Bool {
|
|
621
|
+
OverlayCompactHitRegion.contains(
|
|
622
|
+
point,
|
|
623
|
+
in: hostBounds,
|
|
624
|
+
expanded: state.isExpanded,
|
|
625
|
+
docked: state.isDocked
|
|
626
|
+
)
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
@discardableResult
|
|
630
|
+
func beginPointerInteraction() -> Bool {
|
|
631
|
+
runtime.beginPointerInteraction()
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
func endPointerInteraction(drainPendingPresentation: Bool) {
|
|
635
|
+
runtime.endPointerInteraction()
|
|
636
|
+
guard drainPendingPresentation,
|
|
637
|
+
runtime.claimPendingPresentationIfIdle() else { return }
|
|
638
|
+
performPresentationFrameUpdate(animated: pendingPresentationAnimated)
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// MARK: - Single-owner geometry pipeline
|
|
642
|
+
public func updateWindowFrame(animated: Bool = true) {
|
|
643
|
+
pendingPresentationAnimated = animated
|
|
644
|
+
guard runtime.requestPresentationGeometry() else { return }
|
|
645
|
+
performPresentationFrameUpdate(animated: animated)
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
private func performPresentationFrameUpdate(animated: Bool) {
|
|
649
|
+
guard let window else {
|
|
650
|
+
finishGeometryActivity()
|
|
651
|
+
return
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
let targetSize = state.isExpanded ? summarySize : bubbleSize
|
|
655
|
+
let currentFrame = window.frame
|
|
656
|
+
guard let visible = presentationVisibleFrame(for: currentFrame) else {
|
|
657
|
+
finishGeometryActivity()
|
|
658
|
+
return
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
var newOrigin = NSPoint(
|
|
662
|
+
x: currentFrame.maxX - targetSize.width,
|
|
663
|
+
y: currentFrame.maxY - targetSize.height
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
if state.isExpanded {
|
|
667
|
+
newOrigin = OverlayScreenGeometry.clamp(
|
|
668
|
+
newOrigin,
|
|
669
|
+
windowSize: targetSize,
|
|
670
|
+
to: visible
|
|
671
|
+
)
|
|
672
|
+
} else {
|
|
673
|
+
state.dockEdge = .right
|
|
674
|
+
newOrigin.x = visible.maxX - targetSize.width
|
|
675
|
+
newOrigin.y = max(visible.minY, min(newOrigin.y, visible.maxY - targetSize.height))
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
let targetFrame = NSRect(origin: newOrigin, size: targetSize)
|
|
679
|
+
let collapsedAfterAnimation = !state.isExpanded
|
|
680
|
+
if collapsedAfterAnimation {
|
|
681
|
+
suppressHoverUntilPointerMoves()
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
let completed: () -> Void = { [weak self] in
|
|
685
|
+
guard let self else { return }
|
|
686
|
+
self.window.orderFrontRegardless()
|
|
687
|
+
self.presentationFrameDidSet(targetOrigin: newOrigin, collapsed: collapsedAfterAnimation)
|
|
688
|
+
let startedNext = self.finishGeometryActivity()
|
|
689
|
+
if !startedNext, collapsedAfterAnimation, !self.state.isExpanded {
|
|
690
|
+
self.scheduleTuck()
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
if approximatelyEqual(window.frame, targetFrame) || !animated {
|
|
695
|
+
window.setFrame(targetFrame, display: true)
|
|
696
|
+
completed()
|
|
697
|
+
return
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
NSAnimationContext.runAnimationGroup({ context in
|
|
701
|
+
context.duration = 0.16
|
|
702
|
+
context.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
|
|
703
|
+
context.allowsImplicitAnimation = true
|
|
704
|
+
window.animator().setFrame(targetFrame, display: true)
|
|
705
|
+
}, completionHandler: completed)
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
private func presentationFrameDidSet(targetOrigin: NSPoint, collapsed: Bool) {
|
|
709
|
+
if collapsed && !state.isExpanded {
|
|
710
|
+
saveWindowPosition(targetOrigin)
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
@discardableResult
|
|
715
|
+
private func finishGeometryActivity() -> Bool {
|
|
716
|
+
let shouldRunPendingPresentation = runtime.completeGeometry()
|
|
717
|
+
if shouldRunPendingPresentation {
|
|
718
|
+
performPresentationFrameUpdate(animated: pendingPresentationAnimated)
|
|
719
|
+
return true
|
|
720
|
+
}
|
|
721
|
+
reconcilePointerAfterGeometryIfNeeded()
|
|
722
|
+
return false
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
private func reconcilePointerAfterGeometryIfNeeded() {
|
|
726
|
+
guard needsPointerReconciliationAfterGeometry,
|
|
727
|
+
let contentView = window?.contentView,
|
|
728
|
+
let window else { return }
|
|
729
|
+
needsPointerReconciliationAfterGeometry = false
|
|
730
|
+
|
|
731
|
+
let pointInWindow = window.convertPoint(fromScreen: NSEvent.mouseLocation)
|
|
732
|
+
let pointInHost = contentView.convert(pointInWindow, from: nil)
|
|
733
|
+
if isPointerInteractive(at: pointInHost, in: contentView.bounds) {
|
|
734
|
+
handleMouseEntered(at: pointInHost)
|
|
735
|
+
} else {
|
|
736
|
+
handleMouseExited()
|
|
737
|
+
}
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
private func approximatelyEqual(_ lhs: NSRect, _ rhs: NSRect, tolerance: CGFloat = 0.5) -> Bool {
|
|
741
|
+
abs(lhs.origin.x - rhs.origin.x) <= tolerance &&
|
|
742
|
+
abs(lhs.origin.y - rhs.origin.y) <= tolerance &&
|
|
743
|
+
abs(lhs.size.width - rhs.size.width) <= tolerance &&
|
|
744
|
+
abs(lhs.size.height - rhs.size.height) <= tolerance
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
// MARK: - Edge Half-Tuck Support
|
|
748
|
+
public func cancelTuckTimer() {
|
|
749
|
+
tuckTimer?.invalidate()
|
|
750
|
+
tuckTimer = nil
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
public func scheduleTuck() {
|
|
754
|
+
cancelTuckTimer()
|
|
755
|
+
guard !state.isExpanded,
|
|
756
|
+
!state.isPinned,
|
|
757
|
+
!isInteractingOrDragging,
|
|
758
|
+
!state.isDocked,
|
|
759
|
+
!isGeometryTransitioning else { return }
|
|
760
|
+
|
|
761
|
+
state.dockEdge = .right
|
|
762
|
+
tuckTimer = Timer.scheduledTimer(withTimeInterval: edgeTuckIdleInterval, repeats: false) { [weak self] _ in
|
|
763
|
+
self?.tuckBubble(animated: true)
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
private func suppressHoverUntilPointerMoves() {
|
|
768
|
+
hoverGate.suppress(at: NSEvent.mouseLocation)
|
|
769
|
+
cancelDwellTimer()
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private func pointerMovementRearmedHover() -> Bool {
|
|
773
|
+
hoverGate.allowsHover(at: NSEvent.mouseLocation)
|
|
774
|
+
}
|
|
775
|
+
|
|
776
|
+
/// Compact docking is visual-only. Circle and pill share the same stationary
|
|
777
|
+
/// 76x76 host; no NSWindow frame is changed here.
|
|
778
|
+
public func tuckBubble(animated: Bool = true) {
|
|
779
|
+
guard !state.isExpanded,
|
|
780
|
+
!state.isPinned,
|
|
781
|
+
!isInteractingOrDragging,
|
|
782
|
+
!state.isDocked,
|
|
783
|
+
!isGeometryTransitioning else { return }
|
|
784
|
+
|
|
785
|
+
cancelTuckTimer()
|
|
786
|
+
state.dockEdge = .right
|
|
787
|
+
suppressHoverUntilPointerMoves()
|
|
788
|
+
state.isDocked = true
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
public func unTuckBubble(pointerLocationInHost: NSPoint? = nil, animated: Bool = true) {
|
|
792
|
+
cancelTuckTimer()
|
|
793
|
+
guard state.isDocked, !isGeometryTransitioning else { return }
|
|
794
|
+
state.dockEdge = .right
|
|
795
|
+
state.isDocked = false
|
|
796
|
+
|
|
797
|
+
if let pointerLocationInHost,
|
|
798
|
+
OverlayCompactHitRegion.contains(
|
|
799
|
+
pointerLocationInHost,
|
|
800
|
+
in: NSRect(origin: .zero, size: bubbleSize),
|
|
801
|
+
expanded: false,
|
|
802
|
+
docked: false
|
|
803
|
+
),
|
|
804
|
+
!isInteractingOrDragging {
|
|
805
|
+
resetDwellTimer()
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
// MARK: - Hover-Dwell Detection
|
|
810
|
+
public func cancelDwellTimer() {
|
|
811
|
+
hoverDwellTimer?.invalidate()
|
|
812
|
+
hoverDwellTimer = nil
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
public func handleMouseEntered(at point: NSPoint) {
|
|
816
|
+
if isGeometryTransitioning {
|
|
817
|
+
needsPointerReconciliationAfterGeometry = true
|
|
818
|
+
return
|
|
819
|
+
}
|
|
820
|
+
guard pointerMovementRearmedHover() else { return }
|
|
821
|
+
|
|
822
|
+
cancelTuckTimer()
|
|
823
|
+
collapseTimer?.invalidate()
|
|
824
|
+
collapseTimer = nil
|
|
825
|
+
|
|
826
|
+
if state.isDocked {
|
|
827
|
+
unTuckBubble(pointerLocationInHost: point, animated: true)
|
|
828
|
+
return
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
guard !state.isExpanded, !isInteractingOrDragging else { return }
|
|
832
|
+
resetDwellTimer()
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
public func handleMouseMoved(at point: NSPoint) {
|
|
836
|
+
if isGeometryTransitioning {
|
|
837
|
+
needsPointerReconciliationAfterGeometry = true
|
|
838
|
+
return
|
|
839
|
+
}
|
|
840
|
+
guard pointerMovementRearmedHover() else { return }
|
|
841
|
+
|
|
842
|
+
if state.isDocked {
|
|
843
|
+
unTuckBubble(pointerLocationInHost: point, animated: true)
|
|
844
|
+
return
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
guard !state.isExpanded, !isInteractingOrDragging else { return }
|
|
848
|
+
resetDwellTimer()
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
private func resetDwellTimer() {
|
|
852
|
+
cancelDwellTimer()
|
|
853
|
+
guard !isInteractingOrDragging, !isGeometryTransitioning else { return }
|
|
854
|
+
hoverDwellTimer = Timer.scheduledTimer(withTimeInterval: 0.4, repeats: false) { [weak self] _ in
|
|
855
|
+
guard let self,
|
|
856
|
+
!self.state.isExpanded,
|
|
857
|
+
!self.isInteractingOrDragging,
|
|
858
|
+
!self.isGeometryTransitioning else { return }
|
|
859
|
+
self.state.expand()
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
public func handleMouseExited() {
|
|
864
|
+
cancelDwellTimer()
|
|
865
|
+
if isGeometryTransitioning {
|
|
866
|
+
needsPointerReconciliationAfterGeometry = true
|
|
867
|
+
return
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
if state.isExpanded && !state.isPinned && !isInteractingOrDragging {
|
|
871
|
+
scheduleAutoCollapseTimer()
|
|
872
|
+
} else if !state.isExpanded && !state.isDocked && !isInteractingOrDragging {
|
|
873
|
+
scheduleTuck()
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
private func scheduleAutoCollapseTimer() {
|
|
878
|
+
collapseTimer?.invalidate()
|
|
879
|
+
collapseTimer = Timer.scheduledTimer(withTimeInterval: 0.8, repeats: false) { [weak self] _ in
|
|
880
|
+
guard let self,
|
|
881
|
+
self.state.isExpanded,
|
|
882
|
+
!self.state.isPinned,
|
|
883
|
+
!self.isInteractingOrDragging,
|
|
884
|
+
!self.isGeometryTransitioning else { return }
|
|
885
|
+
|
|
886
|
+
if self.isPointerInAppWindowOrPopover() {
|
|
887
|
+
self.scheduleAutoCollapseTimer()
|
|
888
|
+
return
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
self.state.collapse()
|
|
892
|
+
}
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
private func isPointerInAppWindowOrPopover() -> Bool {
|
|
896
|
+
let mouseLocation = NSEvent.mouseLocation
|
|
897
|
+
for appWindow in NSApp.windows {
|
|
898
|
+
guard appWindow.isVisible,
|
|
899
|
+
appWindow.alphaValue > 0.01,
|
|
900
|
+
appWindow.frame.width > 10,
|
|
901
|
+
appWindow.frame.height > 10 else {
|
|
902
|
+
continue
|
|
903
|
+
}
|
|
904
|
+
if let window, appWindow == window {
|
|
905
|
+
if appWindow.frame.contains(mouseLocation),
|
|
906
|
+
let contentView = appWindow.contentView {
|
|
907
|
+
let pointInWindow = appWindow.convertPoint(fromScreen: mouseLocation)
|
|
908
|
+
let pointInHost = contentView.convert(pointInWindow, from: nil)
|
|
909
|
+
if isPointerInteractive(at: pointInHost, in: contentView.bounds) {
|
|
910
|
+
return true
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
} else {
|
|
914
|
+
if appWindow.frame.contains(mouseLocation) {
|
|
915
|
+
return true
|
|
916
|
+
}
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
return false
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
// MARK: - Drag Snap
|
|
923
|
+
func performMagneticSnap(for window: NSWindow, pointerLocation: NSPoint) {
|
|
924
|
+
guard runtime.beginSnapGeometry() else {
|
|
925
|
+
if runtime.claimPendingPresentationIfIdle() {
|
|
926
|
+
performPresentationFrameUpdate(animated: pendingPresentationAnimated)
|
|
927
|
+
}
|
|
928
|
+
return
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
guard let visible = resolvedVisibleFrame(for: window.frame, preferredPoint: pointerLocation) else {
|
|
932
|
+
let startedNext = finishGeometryActivity()
|
|
933
|
+
if !startedNext, !state.isExpanded {
|
|
934
|
+
scheduleTuck()
|
|
935
|
+
}
|
|
936
|
+
return
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
let frame = window.frame
|
|
940
|
+
var targetOrigin = frame.origin
|
|
941
|
+
let distLeft = abs(frame.minX - visible.minX)
|
|
942
|
+
let distRight = abs(visible.maxX - frame.maxX)
|
|
943
|
+
let distTop = abs(visible.maxY - frame.maxY)
|
|
944
|
+
let distBottom = abs(frame.minY - visible.minY)
|
|
945
|
+
|
|
946
|
+
if state.isExpanded {
|
|
947
|
+
if distLeft < snapThreshold {
|
|
948
|
+
targetOrigin.x = visible.minX + snapMargin
|
|
949
|
+
} else if distRight < snapThreshold {
|
|
950
|
+
targetOrigin.x = visible.maxX - frame.width - snapMargin
|
|
951
|
+
}
|
|
952
|
+
if distTop < snapThreshold {
|
|
953
|
+
targetOrigin.y = visible.maxY - frame.height - snapMargin
|
|
954
|
+
} else if distBottom < snapThreshold {
|
|
955
|
+
targetOrigin.y = visible.minY + snapMargin
|
|
956
|
+
}
|
|
957
|
+
targetOrigin = OverlayScreenGeometry.clamp(
|
|
958
|
+
targetOrigin,
|
|
959
|
+
windowSize: frame.size,
|
|
960
|
+
to: visible
|
|
961
|
+
)
|
|
962
|
+
} else {
|
|
963
|
+
state.dockEdge = .right
|
|
964
|
+
targetOrigin.x = visible.maxX - frame.width
|
|
965
|
+
if distTop < snapThreshold {
|
|
966
|
+
targetOrigin.y = visible.maxY - frame.height - snapMargin
|
|
967
|
+
} else if distBottom < snapThreshold {
|
|
968
|
+
targetOrigin.y = visible.minY + snapMargin
|
|
969
|
+
} else {
|
|
970
|
+
targetOrigin.y = max(
|
|
971
|
+
visible.minY + snapMargin,
|
|
972
|
+
min(targetOrigin.y, visible.maxY - frame.height - snapMargin)
|
|
973
|
+
)
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
let targetFrame = NSRect(origin: targetOrigin, size: frame.size)
|
|
978
|
+
suppressHoverUntilPointerMoves()
|
|
979
|
+
|
|
980
|
+
let completed: () -> Void = { [weak self] in
|
|
981
|
+
guard let self else { return }
|
|
982
|
+
self.window.orderFrontRegardless()
|
|
983
|
+
self.saveWindowPosition(targetOrigin)
|
|
984
|
+
let startedNext = self.finishGeometryActivity()
|
|
985
|
+
if !startedNext, !self.state.isExpanded {
|
|
986
|
+
self.scheduleTuck()
|
|
987
|
+
}
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
if approximatelyEqual(window.frame, targetFrame) {
|
|
991
|
+
window.setFrame(targetFrame, display: true)
|
|
992
|
+
completed()
|
|
993
|
+
return
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
NSAnimationContext.runAnimationGroup({ context in
|
|
997
|
+
context.duration = 0.16
|
|
998
|
+
context.timingFunction = CAMediaTimingFunction(name: .easeOut)
|
|
999
|
+
context.allowsImplicitAnimation = true
|
|
1000
|
+
window.animator().setFrame(targetFrame, display: true)
|
|
1001
|
+
}, completionHandler: completed)
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
// MARK: - Position Persistence
|
|
1005
|
+
private let positionKey = "CodexFlowOverlayWindowPosition"
|
|
1006
|
+
|
|
1007
|
+
public func saveWindowPosition(_ origin: NSPoint) {
|
|
1008
|
+
let dict: [String: Double] = ["x": Double(origin.x), "y": Double(origin.y)]
|
|
1009
|
+
UserDefaults.standard.set(dict, forKey: positionKey)
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
private func loadSavedPosition() -> NSRect? {
|
|
1013
|
+
guard let dict = UserDefaults.standard.dictionary(forKey: positionKey) as? [String: Double],
|
|
1014
|
+
let x = dict["x"],
|
|
1015
|
+
let y = dict["y"] else {
|
|
1016
|
+
return nil
|
|
1017
|
+
}
|
|
1018
|
+
return NSRect(x: CGFloat(x), y: CGFloat(y), width: bubbleSize.width, height: bubbleSize.height)
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
private func normalizedCollapsedFrame(_ frame: NSRect) -> NSRect {
|
|
1022
|
+
let visible = OverlayScreenGeometry.presentationVisibleFrame(for: frame, among: visibleFrames)
|
|
1023
|
+
?? NSScreen.main?.visibleFrame
|
|
1024
|
+
?? NSRect(x: 0, y: 0, width: 1440, height: 900)
|
|
1025
|
+
let x = visible.maxX - bubbleSize.width
|
|
1026
|
+
let y = max(visible.minY, min(frame.origin.y, visible.maxY - bubbleSize.height))
|
|
1027
|
+
return NSRect(origin: NSPoint(x: x, y: y), size: bubbleSize)
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
private func defaultPosition(for size: NSSize) -> NSRect {
|
|
1031
|
+
let screen = NSScreen.main?.visibleFrame ?? NSRect(x: 0, y: 0, width: 1440, height: 900)
|
|
1032
|
+
let x = screen.maxX - size.width
|
|
1033
|
+
let y = screen.maxY - size.height - 32
|
|
1034
|
+
return NSRect(x: x, y: y, width: size.width, height: size.height)
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
public func refreshTelemetry() {
|
|
1038
|
+
DispatchQueue.global(qos: .userInitiated).async {
|
|
1039
|
+
let latest = TelemetryQueryEngine.shared.loadLatestRun()
|
|
1040
|
+
DispatchQueue.main.async {
|
|
1041
|
+
if let run = latest {
|
|
1042
|
+
self.state.update(run: run)
|
|
1043
|
+
} else {
|
|
1044
|
+
self.state.latestRun = nil
|
|
1045
|
+
self.state.isTaskRunning = false
|
|
1046
|
+
self.state.loadHistory()
|
|
1047
|
+
self.state.loadStats()
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
}
|