Skip to content

Commit ae1924f

Browse files
committed
Merge branch 'feature/plugin-terminal' into develop
2 parents e63efca + 735d86b commit ae1924f

9 files changed

Lines changed: 378 additions & 18 deletions

File tree

Core/Package.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ let package = Package(
8686
"SuggestionWidget",
8787
"AXExtension",
8888
"Logger",
89+
"ChatService",
8990
.product(name: "AsyncAlgorithms", package: "swift-async-algorithms"),
9091
]
9192
),
@@ -147,5 +148,8 @@ let package = Package(
147148
dependencies: ["OpenAIService"]
148149
),
149150
.target(name: "Preferences"),
151+
.target(name: "ChatPlugins", dependencies: ["OpenAIService", "Environment", "Terminal"]),
152+
.target(name: "Terminal"),
153+
.target(name: "ChatService", dependencies: ["OpenAIService", "ChatPlugins", "Environment"]),
150154
]
151155
)

Core/Sources/ActiveApplicationMonitor/ActiveApplicationMonitor.swift

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,16 @@ import AppKit
22

33
public final class ActiveApplicationMonitor {
44
static let shared = ActiveApplicationMonitor()
5-
var latestXcode: NSRunningApplication?
5+
var latestXcode: NSRunningApplication? = NSWorkspace.shared.runningApplications
6+
.first(where: \.isXcode)
67
var activeApplication = NSWorkspace.shared.runningApplications.first(where: \.isActive) {
78
didSet {
89
if activeApplication?.isXcode ?? false {
910
latestXcode = activeApplication
1011
}
1112
}
1213
}
14+
1315
private var continuations: [UUID: AsyncStream<NSRunningApplication?>.Continuation] = [:]
1416

1517
private init() {
@@ -40,7 +42,7 @@ public final class ActiveApplicationMonitor {
4042
}
4143
return nil
4244
}
43-
45+
4446
public static var latestXcode: NSRunningApplication? { shared.latestXcode }
4547

4648
public static func createStream() -> AsyncStream<NSRunningApplication?> {
@@ -72,7 +74,6 @@ public final class ActiveApplicationMonitor {
7274
}
7375
}
7476

75-
extension NSRunningApplication {
76-
public var isXcode: Bool { bundleIdentifier == "com.apple.dt.Xcode" }
77+
public extension NSRunningApplication {
78+
var isXcode: Bool { bundleIdentifier == "com.apple.dt.Xcode" }
7779
}
78-
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
import Foundation
2+
import OpenAIService
3+
4+
public protocol ChatPlugin {
5+
/// Should be [a-zA-Z0-9]+
6+
static var command: String { get }
7+
var name: String { get }
8+
9+
init(inside chatGPTService: ChatGPTServiceType, delegate: ChatPluginDelegate)
10+
func send(content: String) async
11+
func cancel() async
12+
}
13+
14+
public protocol ChatPluginDelegate: AnyObject {
15+
func pluginDidStart(_ plugin: ChatPlugin)
16+
func pluginDidEnd(_ plugin: ChatPlugin)
17+
}
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import Environment
2+
import Foundation
3+
import OpenAIService
4+
import Terminal
5+
6+
public actor TerminalChatPlugin: ChatPlugin {
7+
public static var command: String { "run" }
8+
public nonisolated var name: String { "Terminal" }
9+
10+
let chatGPTService: ChatGPTServiceType
11+
var terminal: TerminalType = Terminal()
12+
var isCancelled = false
13+
weak var delegate: ChatPluginDelegate?
14+
15+
public init(inside chatGPTService: ChatGPTServiceType, delegate: ChatPluginDelegate) {
16+
self.chatGPTService = chatGPTService
17+
self.delegate = delegate
18+
}
19+
20+
public func send(content: String) async {
21+
delegate?.pluginDidStart(self)
22+
23+
let id = "\(Self.command)-\(UUID().uuidString)"
24+
var message = ChatMessage(id: id, role: .assistant, content: "")
25+
var outputContent = "" {
26+
didSet {
27+
message.content = """
28+
```
29+
\(outputContent)
30+
```
31+
"""
32+
}
33+
}
34+
35+
do {
36+
let fileURL = try await Environment.fetchCurrentFileURL()
37+
let projectURL = try await Environment.fetchCurrentProjectRootURL(fileURL)
38+
39+
await chatGPTService.mutateHistory { history in
40+
history.append(.init(role: .user, content: "Run command: \(content)"))
41+
}
42+
43+
if isCancelled { throw CancellationError() }
44+
45+
let output = terminal.streamCommand(
46+
"/bin/bash",
47+
arguments: ["-c", content],
48+
currentDirectoryPath: projectURL?.path ?? fileURL.path,
49+
environment: [
50+
"PROJECT_ROOT": projectURL?.path ?? fileURL.path,
51+
"FILE_PATH": fileURL.path,
52+
]
53+
)
54+
55+
for try await content in output {
56+
if isCancelled { throw CancellationError() }
57+
await chatGPTService.mutateHistory { history in
58+
if history.last?.id == id {
59+
history.removeLast()
60+
}
61+
outputContent += content
62+
history.append(message)
63+
}
64+
}
65+
outputContent += "\n[finished]"
66+
await chatGPTService.mutateHistory { history in
67+
if history.last?.id == id {
68+
history.removeLast()
69+
}
70+
history.append(message)
71+
}
72+
} catch let error as Terminal.TerminationError {
73+
outputContent += "\n[error: \(error.status)]"
74+
await chatGPTService.mutateHistory { history in
75+
if history.last?.id == id {
76+
history.removeLast()
77+
}
78+
history.append(message)
79+
}
80+
} catch {
81+
outputContent += "\n[error: \(error.localizedDescription)]"
82+
await chatGPTService.mutateHistory { history in
83+
if history.last?.id == id {
84+
history.removeLast()
85+
}
86+
history.append(message)
87+
}
88+
}
89+
90+
delegate?.pluginDidEnd(self)
91+
}
92+
93+
public func cancel() async {
94+
isCancelled = true
95+
await terminal.terminate()
96+
}
97+
}
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import ChatPlugins
2+
import Foundation
3+
import OpenAIService
4+
5+
public final class ChatService: ObservableObject {
6+
let chatGPTService: ChatGPTServiceType
7+
let plugins = registerPlugins(
8+
TerminalChatPlugin.self
9+
)
10+
var runningPlugin: ChatPlugin?
11+
12+
public init(chatGPTService: ChatGPTServiceType) {
13+
self.chatGPTService = chatGPTService
14+
}
15+
16+
deinit {
17+
print("deinit")
18+
}
19+
20+
public func send(content: String) async throws {
21+
// look for the prefix of content, see if there is something like /command.
22+
// If there is, then we need to find the plugin that can handle this command.
23+
// If there is no such plugin, then we just send the message to the GPT service.
24+
let regex = try NSRegularExpression(pattern: #"^\/([a-zA-Z0-9]+)"#)
25+
let matches = regex.matches(in: content, range: NSRange(content.startIndex..., in: content))
26+
if let match = matches.first {
27+
let command = String(content[Range(match.range(at: 1), in: content)!])
28+
if let pluginType = plugins[command] {
29+
let plugin = pluginType.init(inside: chatGPTService, delegate: self)
30+
await plugin.send(content: String(content.dropFirst(command.count + 1)))
31+
}
32+
} else {
33+
_ = try await chatGPTService.send(content: content, summary: nil)
34+
}
35+
}
36+
37+
public func stopReceivingMessage() async {
38+
if let runningPlugin {
39+
await runningPlugin.cancel()
40+
}
41+
await chatGPTService.stopReceivingMessage()
42+
}
43+
44+
public func clearHistory() async {
45+
if let runningPlugin {
46+
await runningPlugin.cancel()
47+
}
48+
await chatGPTService.clearHistory()
49+
}
50+
}
51+
52+
extension ChatService: ChatPluginDelegate {
53+
public func pluginDidStart(_ plugin: ChatPlugin) {
54+
Task {
55+
await chatGPTService.markReceivingMessage(true)
56+
}
57+
runningPlugin = plugin
58+
}
59+
60+
public func pluginDidEnd(_ plugin: ChatPlugin) {
61+
Task {
62+
await chatGPTService.markReceivingMessage(false)
63+
}
64+
runningPlugin = nil
65+
}
66+
}
67+
68+
func registerPlugins(_ plugins: ChatPlugin.Type...) -> [String: ChatPlugin.Type] {
69+
var all = [String: ChatPlugin.Type]()
70+
for plugin in plugins {
71+
all[plugin.command] = plugin
72+
}
73+
return all
74+
}

Core/Sources/Environment/Environment.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ public enum Environment {
4343

4444
public static var fetchCurrentProjectRootURL: (_ fileURL: URL?) async throws
4545
-> URL? = { fileURL in
46-
if let xcode = ActiveApplicationMonitor.activeXcode {
46+
if let xcode = ActiveApplicationMonitor.activeXcode
47+
?? ActiveApplicationMonitor.latestXcode
48+
{
4749
let application = AXUIElementCreateApplication(xcode.processIdentifier)
4850
let focusedWindow = application.focusedWindow
4951
for child in focusedWindow?.children ?? [] {
@@ -77,7 +79,9 @@ public enum Environment {
7779
}
7880

7981
public static var fetchCurrentFileURL: () async throws -> URL = {
80-
guard let xcode = ActiveApplicationMonitor.activeXcode else {
82+
guard let xcode = ActiveApplicationMonitor.activeXcode
83+
?? ActiveApplicationMonitor.latestXcode
84+
else {
8185
throw FailedToFetchFileURLError()
8286
}
8387

Core/Sources/OpenAIService/ChatGPTService.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public protocol ChatGPTServiceType {
77
func clearHistory() async
88
func mutateSystemPrompt(_ newPrompt: String) async
99
func mutateHistory(_ mutate: (inout [ChatMessage]) -> Void) async
10+
func markReceivingMessage(_ receiving: Bool) async
1011
}
1112

1213
public enum ChatGPTServiceError: Error, LocalizedError {
@@ -166,6 +167,7 @@ public actor ChatGPTService: ChatGPTServiceType, ObservableObject {
166167
}
167168

168169
public func clearHistory() {
170+
stopReceivingMessage()
169171
history = []
170172
}
171173

@@ -176,13 +178,17 @@ public actor ChatGPTService: ChatGPTServiceType, ObservableObject {
176178
public func mutateHistory(_ mutate: (inout [ChatMessage]) -> Void) async {
177179
mutate(&history)
178180
}
181+
182+
public func markReceivingMessage(_ receiving: Bool) {
183+
isReceivingMessage = receiving
184+
}
179185
}
180186

181187
extension ChatGPTService {
182188
func changeBuildCompletionStreamAPI(_ builder: @escaping CompletionStreamAPIBuilder) {
183189
buildCompletionStreamAPI = builder
184190
}
185-
191+
186192
func changeUUIDGenerator(_ generator: @escaping () -> String) {
187193
uuidGenerator = generator
188194
}

Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1+
import ChatService
12
import CopilotModel
23
import Foundation
3-
import SuggestionWidget
44
import OpenAIService
5+
import SuggestionWidget
56

67
struct PresentInWindowSuggestionPresenter {
78
func presentSuggestion(
@@ -46,14 +47,14 @@ struct PresentInWindowSuggestionPresenter {
4647
controller.presentError(error.localizedDescription)
4748
}
4849
}
49-
50+
5051
func presentErrorMessage(_ message: String) {
5152
Task { @MainActor in
5253
let controller = GraphicalUserInterfaceController.shared.suggestionWidget
5354
controller.presentError(message)
5455
}
5556
}
56-
57+
5758
func closeChatRoom(fileURL: URL) {
5859
Task { @MainActor in
5960
let controller = GraphicalUserInterfaceController.shared.suggestionWidget
@@ -63,6 +64,7 @@ struct PresentInWindowSuggestionPresenter {
6364

6465
func presentChatGPTConversation(_ service: ChatGPTService, fileURL: URL) {
6566
let chatRoom = ChatRoom()
67+
let chatService = ChatService(chatGPTService: service)
6668
let cancellable = service.objectWillChange.sink { [weak chatRoom] in
6769
guard let chatRoom else { return }
6870
Task { @MainActor in
@@ -81,31 +83,31 @@ struct PresentInWindowSuggestionPresenter {
8183
_ = cancellable
8284
Task {
8385
do {
84-
_ = try await service.send(content: message)
86+
_ = try await chatService.send(content: message)
8587
} catch {
8688
presentError(error)
8789
}
8890
}
8991
}
9092
chatRoom.onStop = {
9193
Task {
92-
await service.stopReceivingMessage()
94+
await chatService.stopReceivingMessage()
9395
}
9496
}
95-
97+
9698
chatRoom.onClear = {
9799
Task {
98-
await service.clearHistory()
100+
await chatService.clearHistory()
99101
}
100102
}
101-
103+
102104
chatRoom.onClose = {
103105
Task {
104-
await service.stopReceivingMessage()
106+
await chatService.stopReceivingMessage()
105107
closeChatRoom(fileURL: fileURL)
106108
}
107109
}
108-
110+
109111
Task { @MainActor in
110112
let controller = GraphicalUserInterfaceController.shared.suggestionWidget
111113
controller.presentChatRoom(chatRoom, fileURL: fileURL)

0 commit comments

Comments
 (0)