Skip to content

Commit a075ca3

Browse files
committed
Add TerminalChatPlugin
1 parent a7b5bf4 commit a075ca3

File tree

4 files changed

+262
-0
lines changed

4 files changed

+262
-0
lines changed

Core/Package.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,5 +147,7 @@ let package = Package(
147147
dependencies: ["OpenAIService"]
148148
),
149149
.target(name: "Preferences"),
150+
.target(name: "ChatPlugins", dependencies: ["OpenAIService", "Environment"]),
151+
.target(name: "Terminal"),
150152
]
151153
)
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import Foundation
2+
import OpenAIService
3+
4+
public protocol ChatPlugin {
5+
static var command: String { get }
6+
var name: String { get }
7+
8+
init(inside chatGPTService: ChatGPTServiceType, delegate: ChatPluginDelegate)
9+
func send(content: String) async
10+
func cancel() async
11+
}
12+
13+
public protocol ChatPluginDelegate: AnyObject {
14+
func pluginDidStart(_ plugin: ChatPlugin)
15+
func pluginDidEnd(_ plugin: ChatPlugin)
16+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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() {
94+
isCancelled = true
95+
}
96+
}
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
import AppKit
2+
import Foundation
3+
4+
public protocol TerminalType {
5+
func streamCommand(
6+
_ command: String,
7+
arguments: [String],
8+
currentDirectoryPath: String,
9+
environment: [String: String]
10+
) -> AsyncThrowingStream<String, Error>
11+
12+
func runCommand(
13+
_ command: String,
14+
arguments: [String],
15+
currentDirectoryPath: String,
16+
environment: [String: String]
17+
) async throws -> String
18+
}
19+
20+
public final class Terminal: TerminalType, @unchecked Sendable {
21+
var process: Process?
22+
var outputPipe: Pipe?
23+
var inputPipe: Pipe?
24+
25+
public struct TerminationError: Error {
26+
public let reason: Process.TerminationReason
27+
public let status: Int32
28+
}
29+
30+
public init() {}
31+
32+
func getEnvironmentVariables() -> [String: String] {
33+
let env = ProcessInfo.processInfo.environment
34+
.merging(["LANG": "en_US.UTF-8"], uniquingKeysWith: { $1 })
35+
return env
36+
}
37+
38+
public func streamCommand(
39+
_ command: String = "/bin/bash",
40+
arguments: [String],
41+
currentDirectoryPath: String = "/",
42+
environment: [String: String]
43+
) -> AsyncThrowingStream<String, Error> {
44+
self.process?.terminate()
45+
let process = Process()
46+
self.process = process
47+
48+
process.launchPath = command
49+
process.currentDirectoryPath = currentDirectoryPath
50+
process.arguments = arguments
51+
process.environment = getEnvironmentVariables()
52+
.merging(environment, uniquingKeysWith: { $1 })
53+
54+
let outputPipe = Pipe()
55+
process.standardOutput = outputPipe
56+
process.standardError = outputPipe
57+
self.outputPipe = outputPipe
58+
59+
let inputPipe = Pipe()
60+
process.standardInput = inputPipe
61+
self.inputPipe = inputPipe
62+
63+
var continuation: AsyncThrowingStream<String, Error>.Continuation!
64+
let contentStream = AsyncThrowingStream<String, Error> { cont in
65+
continuation = cont
66+
}
67+
68+
Task { [continuation, self] in
69+
let notificationCenter = NotificationCenter.default
70+
let notifications = notificationCenter.notifications(
71+
named: FileHandle.readCompletionNotification,
72+
object: outputPipe.fileHandleForReading
73+
)
74+
for await notification in notifications {
75+
let userInfo = notification.userInfo
76+
if let data = userInfo?[NSFileHandleNotificationDataItem] as? Data,
77+
let content = String(data: data, encoding: .utf8),
78+
!content.isEmpty
79+
{
80+
continuation?.yield(content)
81+
}
82+
if !(self.process?.isRunning ?? false) {
83+
let reason = self.process?.terminationReason ?? .exit
84+
let status = self.process?.terminationStatus ?? 1
85+
if let output = (self.process?.standardOutput as? Pipe)?.fileHandleForReading.readDataToEndOfFile(),
86+
let content = String(data: output, encoding: .utf8),
87+
!content.isEmpty
88+
{
89+
continuation?.yield(content)
90+
}
91+
92+
if status == 0 {
93+
continuation?.finish()
94+
} else {
95+
continuation?.finish(throwing: TerminationError(
96+
reason: reason,
97+
status: status
98+
))
99+
}
100+
break
101+
}
102+
Task { @MainActor in
103+
outputPipe.fileHandleForReading.readInBackgroundAndNotify(forModes: [.common])
104+
}
105+
}
106+
}
107+
108+
Task { @MainActor in
109+
outputPipe.fileHandleForReading.readInBackgroundAndNotify(forModes: [.common])
110+
}
111+
112+
do {
113+
try process.run()
114+
} catch {
115+
continuation.finish(throwing: error)
116+
}
117+
118+
return contentStream
119+
}
120+
121+
public func runCommand(
122+
_ command: String = "/bin/bash",
123+
arguments: [String],
124+
currentDirectoryPath: String = "/",
125+
environment: [String: String]
126+
) async throws -> String {
127+
let stream = streamCommand(
128+
command,
129+
arguments: arguments,
130+
currentDirectoryPath: currentDirectoryPath,
131+
environment: environment
132+
)
133+
var result = ""
134+
for try await output in stream {
135+
result += output
136+
}
137+
return result
138+
}
139+
140+
func writeInput(_ input: String) {
141+
guard let data = input.data(using: .utf8) else {
142+
return
143+
}
144+
145+
inputPipe?.fileHandleForWriting.write(data)
146+
inputPipe?.fileHandleForWriting.closeFile()
147+
}
148+
}

0 commit comments

Comments
 (0)