Skip to content

Commit 156e1d6

Browse files
committed
A working version of realtime suggestion
1 parent 382fd3a commit 156e1d6

File tree

7 files changed

+180
-53
lines changed

7 files changed

+180
-53
lines changed

Core/Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ let package = Package(
1717
),
1818
],
1919
dependencies: [
20-
.package(url: "https://github.com/ChimeHQ/LanguageClient", exact: "0.3.1"),
20+
.package(url: "https://github.com/ChimeHQ/LanguageClient", from: "0.3.1"),
2121
],
2222
targets: [
2323
.target(

Core/Sources/CopilotModel/ExportedFromLSP.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,3 +2,7 @@ import LanguageServerProtocol
22

33
public typealias CursorPosition = LanguageServerProtocol.Position
44
public typealias CursorRange = LanguageServerProtocol.LSPRange
5+
6+
public extension CursorPosition {
7+
static var outOfScope: CursorPosition { .init(line: -1, character: -1) }
8+
}

Core/Sources/Service/AutoTrigger.swift

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,30 +4,37 @@ import XPCShared
44
actor AutoTrigger {
55
static let shared = AutoTrigger()
66

7-
var listeners = Set<ObjectIdentifier>()
8-
private var task: Task<Void, Error>?
7+
private var listeners = Set<ObjectIdentifier>()
8+
var eventObserver: CGEventObserverType = CGEventObserver()
9+
var task: Task<Void, Error>?
910

1011
private init() {}
1112

1213
func start(by listener: ObjectIdentifier) {
1314
listeners.insert(listener)
14-
guard task == nil else { return }
15-
task = Task {
16-
while !Task.isCancelled {
17-
guard UserDefaults.shared.bool(forKey: SettingsKey.isAutoTriggerEnabled) else {
18-
continue
19-
}
20-
try await Task.sleep(nanoseconds: 2_000_000_000)
15+
if task == nil {
16+
task = Task { [stream = eventObserver.stream] in
17+
var triggerTask: Task<Void, Error>?
2118
try? await Environment.triggerAction("Realtime Suggestions")
22-
print("run")
19+
for await _ in stream {
20+
triggerTask?.cancel()
21+
if Task.isCancelled { break }
22+
triggerTask = Task {
23+
try? await Task.sleep(nanoseconds: 2_000_000_000)
24+
if Task.isCancelled { return }
25+
try? await Environment.triggerAction("Realtime Suggestions")
26+
}
27+
}
2328
}
2429
}
30+
eventObserver.activateIfPossible()
2531
}
2632

2733
func stop(by listener: ObjectIdentifier) {
2834
listeners.remove(listener)
2935
guard listeners.isEmpty else { return }
3036
task?.cancel()
3137
task = nil
38+
eventObserver.deactivate()
3239
}
3340
}
Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import Cocoa
2+
import Foundation
3+
4+
public protocol CGEventObserverType {
5+
@discardableResult
6+
func activateIfPossible() -> Bool
7+
func deactivate()
8+
var stream: AsyncStream<Void> { get }
9+
var isEnabled: Bool { get }
10+
}
11+
12+
final class CGEventObserver: CGEventObserverType {
13+
let stream: AsyncStream<Void>
14+
var isEnabled: Bool { port != nil }
15+
16+
private var continuation: AsyncStream<Void>.Continuation
17+
private var port: CFMachPort?
18+
private let eventsOfInterest: Set<CGEventType> = [.keyUp, .leftMouseUp, .mouseMoved]
19+
private let tapLocation: CGEventTapLocation = .cghidEventTap
20+
private let tapPlacement: CGEventTapPlacement = .tailAppendEventTap
21+
private let tapOptions: CGEventTapOptions = .listenOnly
22+
private var retryTask: Task<Void, Error>?
23+
24+
deinit {
25+
continuation.finish()
26+
CFMachPortInvalidate(port)
27+
}
28+
29+
init() {
30+
var continuation: AsyncStream<Void>.Continuation!
31+
stream = AsyncStream { c in
32+
continuation = c
33+
}
34+
self.continuation = continuation
35+
}
36+
37+
public func deactivate() {
38+
retryTask?.cancel()
39+
retryTask = nil
40+
guard let port = port else { return }
41+
print("Deactivate")
42+
CFMachPortInvalidate(port)
43+
self.port = nil
44+
}
45+
46+
@discardableResult
47+
public func activateIfPossible() -> Bool {
48+
guard AXIsProcessTrusted() else { return false }
49+
guard port == nil else { return true }
50+
51+
let eoi = UInt64(eventsOfInterest.reduce(into: 0) { $0 |= 1 << $1.rawValue })
52+
53+
func callback(
54+
tapProxy _: CGEventTapProxy,
55+
eventType: CGEventType,
56+
event: CGEvent,
57+
continuationPointer: UnsafeMutableRawPointer?
58+
) -> Unmanaged<CGEvent>? {
59+
guard AXIsProcessTrusted() else {
60+
return .passRetained(event)
61+
}
62+
63+
if eventType == .tapDisabledByTimeout || eventType == .tapDisabledByUserInput {
64+
return .passRetained(event)
65+
}
66+
67+
if let continuation = continuationPointer?.assumingMemoryBound(to: AsyncStream<Void>.Continuation.self) {
68+
continuation.pointee.yield(())
69+
}
70+
71+
return .passRetained(event)
72+
}
73+
74+
let tapLocation = self.tapLocation
75+
let tapPlacement = self.tapPlacement
76+
let tapOptions = self.tapOptions
77+
78+
guard let port = withUnsafeMutablePointer(to: &continuation, { pointer in
79+
CGEvent.tapCreate(
80+
tap: tapLocation,
81+
place: tapPlacement,
82+
options: tapOptions,
83+
eventsOfInterest: eoi,
84+
callback: callback,
85+
userInfo: pointer
86+
)
87+
}) else {
88+
retryTask = Task {
89+
try? await Task.sleep(nanoseconds: 2_000_000_000)
90+
try Task.checkCancellation()
91+
activateIfPossible()
92+
}
93+
return false
94+
}
95+
self.port = port
96+
let runLoopSource = CFMachPortCreateRunLoopSource(kCFAllocatorDefault, port, 0)
97+
CFRunLoopAddSource(RunLoop.main.getCFRunLoop(), runLoopSource, .commonModes)
98+
return true
99+
}
100+
}

Core/Sources/Service/Environment.swift

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,10 +110,17 @@ enum Environment {
110110
guard let activeXcode = xcodes.first(where: { $0.isActive }) else { return }
111111
let bundleName = Bundle.main.object(forInfoDictionaryKey: "EXTENSION_BUNDLE_NAME") as! String
112112

113+
/// check if menu is open, if not, click the menu item.
113114
let appleScript = """
114115
tell application "System Events"
115116
set proc to item 1 of (processes whose unix id is \(activeXcode.processIdentifier))
116117
tell proc
118+
repeat with theMenu in menus of menu bar 1
119+
set theValue to value of attribute "AXVisibleChildren" of theMenu
120+
if theValue is not {} then
121+
return
122+
end if
123+
end repeat
117124
click menu item "\(name)" of menu 1 of menu item "\(bundleName)" of menu 1 of menu bar item "Editor" of menu bar 1
118125
end tell
119126
end tell

Core/Sources/Service/Workspace.swift

Lines changed: 49 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -6,15 +6,19 @@ import XPCShared
66

77
@ServiceActor
88
final class Filespace {
9+
struct Snapshot: Equatable {
10+
var linesHash: Int
11+
var cursorPosition: CursorPosition
12+
}
13+
914
let fileURL: URL
1015
var suggestions: [CopilotCompletion] = [] {
1116
didSet { lastSuggestionUpdateTime = Environment.now() }
1217
}
1318

1419
var suggestionIndex: Int = 0
15-
var latestContentHash: Int = 0
16-
var latestCursorPosition: CursorPosition = .init(line: -1, character: -1)
1720
var currentSuggestionLineRange: ClosedRange<Int>?
21+
var suggestionSourceSnapshot: Snapshot = .init(linesHash: -1, cursorPosition: .outOfScope)
1822

1923
private(set) var lastSuggestionUpdateTime: Date = Environment.now()
2024
var isExpired: Bool {
@@ -50,26 +54,18 @@ final class Workspace {
5054
self.projectRootURL = projectRootURL
5155
}
5256

53-
/// Trigger only when
54-
/// 1. There is no pending suggestion
55-
/// 2. There are pending suggestions, but either content or cursor is changed, and cursor is not inside of suggestion.
5657
func canAutoTriggerGetSuggestions(
5758
forFileAt fileURL: URL,
58-
content: String,
59+
lines: [String],
5960
cursorPosition: CursorPosition
6061
) -> Bool {
6162
guard isRealtimeSuggestionEnabled else { return false }
6263
guard let filespace = filespaces[fileURL] else { return true }
63-
if filespace.suggestions.isEmpty { return true }
64-
if content.hashValue != filespace.latestContentHash { return true }
65-
if cursorPosition != filespace.latestCursorPosition {
66-
if let range = filespace.currentSuggestionLineRange,
67-
range.contains(cursorPosition.line)
68-
{
69-
return false
70-
}
71-
return true
72-
}
64+
if let range = filespace.currentSuggestionLineRange,
65+
range.contains(cursorPosition.line)
66+
{ return false }
67+
if lines.hashValue != filespace.suggestionSourceSnapshot.linesHash { return true }
68+
if cursorPosition != filespace.suggestionSourceSnapshot.cursorPosition { return true }
7369
return false
7470
}
7571

@@ -91,15 +87,19 @@ final class Workspace {
9187
if filespaces[fileURL] == nil {
9288
filespaces[fileURL] = filespace
9389
}
94-
filespace.latestContentHash = content.hashValue
95-
filespace.latestCursorPosition = cursorPosition
9690
var extraInfo = SuggestionInjector.ExtraInfo()
9791

9892
injector.rejectCurrentSuggestions(
9993
from: &lines,
10094
cursorPosition: &cursorPosition,
10195
extraInfo: &extraInfo
10296
)
97+
98+
filespace.suggestionSourceSnapshot = .init(
99+
linesHash: lines.hashValue,
100+
cursorPosition: cursorPosition
101+
)
102+
103103
let completions = try await service.getCompletions(
104104
fileURL: fileURL,
105105
content: lines.joined(separator: ""),
@@ -124,6 +124,9 @@ final class Workspace {
124124
count: completions.count,
125125
extraInfo: &extraInfo
126126
)
127+
128+
filespace.currentSuggestionLineRange = extraInfo.suggestionRange
129+
127130
return .init(
128131
content: String(lines.joined(separator: "")),
129132
newCursor: cursorPosition,
@@ -138,16 +141,16 @@ final class Workspace {
138141
cursorPosition: CursorPosition
139142
) -> UpdatedContent {
140143
lastTriggerDate = Environment.now()
141-
guard let fileSuggestion = filespaces[fileURL],
142-
fileSuggestion.suggestions.count > 1
144+
guard let filespace = filespaces[fileURL],
145+
filespace.suggestions.count > 1
143146
else { return .init(content: content, modifications: []) }
144147
var cursorPosition = cursorPosition
145-
fileSuggestion.suggestionIndex += 1
146-
if fileSuggestion.suggestionIndex >= fileSuggestion.suggestions.endIndex {
147-
fileSuggestion.suggestionIndex = 0
148+
filespace.suggestionIndex += 1
149+
if filespace.suggestionIndex >= filespace.suggestions.endIndex {
150+
filespace.suggestionIndex = 0
148151
}
149152

150-
let suggestion = fileSuggestion.suggestions[fileSuggestion.suggestionIndex]
153+
let suggestion = filespace.suggestions[filespace.suggestionIndex]
151154
let injector = SuggestionInjector()
152155
var extraInfo = SuggestionInjector.ExtraInfo()
153156
var lines = lines
@@ -159,10 +162,13 @@ final class Workspace {
159162
injector.proposeSuggestion(
160163
intoContentWithoutSuggestion: &lines,
161164
completion: suggestion,
162-
index: fileSuggestion.suggestionIndex,
163-
count: fileSuggestion.suggestions.count,
165+
index: filespace.suggestionIndex,
166+
count: filespace.suggestions.count,
164167
extraInfo: &extraInfo
165168
)
169+
170+
filespace.currentSuggestionLineRange = extraInfo.suggestionRange
171+
166172
return .init(
167173
content: String(lines.joined(separator: "")),
168174
newCursor: cursorPosition,
@@ -177,16 +183,16 @@ final class Workspace {
177183
cursorPosition: CursorPosition
178184
) -> UpdatedContent {
179185
lastTriggerDate = Environment.now()
180-
guard let fileSuggestion = filespaces[fileURL],
181-
fileSuggestion.suggestions.count > 1
186+
guard let filespace = filespaces[fileURL],
187+
filespace.suggestions.count > 1
182188
else { return .init(content: content, modifications: []) }
183189
var cursorPosition = cursorPosition
184-
fileSuggestion.suggestionIndex -= 1
185-
if fileSuggestion.suggestionIndex < 0 {
186-
fileSuggestion.suggestionIndex = fileSuggestion.suggestions.endIndex - 1
190+
filespace.suggestionIndex -= 1
191+
if filespace.suggestionIndex < 0 {
192+
filespace.suggestionIndex = filespace.suggestions.endIndex - 1
187193
}
188194
var extraInfo = SuggestionInjector.ExtraInfo()
189-
let suggestion = fileSuggestion.suggestions[fileSuggestion.suggestionIndex]
195+
let suggestion = filespace.suggestions[filespace.suggestionIndex]
190196
let injector = SuggestionInjector()
191197
var lines = lines
192198
injector.rejectCurrentSuggestions(
@@ -197,10 +203,13 @@ final class Workspace {
197203
injector.proposeSuggestion(
198204
intoContentWithoutSuggestion: &lines,
199205
completion: suggestion,
200-
index: fileSuggestion.suggestionIndex,
201-
count: fileSuggestion.suggestions.count,
206+
index: filespace.suggestionIndex,
207+
count: filespace.suggestions.count,
202208
extraInfo: &extraInfo
203209
)
210+
211+
filespace.currentSuggestionLineRange = extraInfo.suggestionRange
212+
204213
return .init(
205214
content: String(lines.joined(separator: "")),
206215
newCursor: cursorPosition,
@@ -215,16 +224,16 @@ final class Workspace {
215224
cursorPosition: CursorPosition
216225
) -> UpdatedContent {
217226
lastTriggerDate = Environment.now()
218-
guard let fileSuggestion = filespaces[fileURL],
219-
!fileSuggestion.suggestions.isEmpty,
220-
fileSuggestion.suggestionIndex >= 0,
221-
fileSuggestion.suggestionIndex < fileSuggestion.suggestions.endIndex
227+
guard let filespace = filespaces[fileURL],
228+
!filespace.suggestions.isEmpty,
229+
filespace.suggestionIndex >= 0,
230+
filespace.suggestionIndex < filespace.suggestions.endIndex
222231
else { return .init(content: content, modifications: []) }
223232

224233
var cursorPosition = cursorPosition
225234
var extraInfo = SuggestionInjector.ExtraInfo()
226-
var allSuggestions = fileSuggestion.suggestions
227-
let suggestion = allSuggestions.remove(at: fileSuggestion.suggestionIndex)
235+
var allSuggestions = filespace.suggestions
236+
let suggestion = allSuggestions.remove(at: filespace.suggestionIndex)
228237
let injector = SuggestionInjector()
229238
var lines = lines
230239
injector.rejectCurrentSuggestions(

Core/Sources/Service/XPCService.swift

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -232,14 +232,14 @@ public class XPCService: NSObject, XPCServiceProtocol {
232232

233233
let canAutoTrigger = workspace.canAutoTriggerGetSuggestions(
234234
forFileAt: fileURL,
235-
content: editor.content,
235+
lines: editor.lines,
236236
cursorPosition: editor.cursorPosition
237237
)
238238
guard canAutoTrigger else {
239239
reply(nil, nil)
240240
return
241241
}
242-
print("update")
242+
243243
let updatedContent = try await workspace.getSuggestedCode(
244244
forFileAt: fileURL,
245245
content: editor.content,

0 commit comments

Comments
 (0)