Skip to content

Commit 2f0ab62

Browse files
committed
Merge branch 'feature/shortcut-chat-plugin' into develop
2 parents b9966db + e8ab57c commit 2f0ab62

File tree

6 files changed

+163
-6
lines changed

6 files changed

+163
-6
lines changed

Core/Package.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ let package = Package(
5050
.package(url: "https://github.com/sparkle-project/Sparkle", from: "2.0.0"),
5151
.package(url: "https://github.com/kishikawakatsumi/KeychainAccess", from: "4.2.2"),
5252
.package(url: "https://github.com/pvieito/PythonKit.git", branch: "master"),
53+
.package(url: "https://github.com/pointfreeco/swift-parsing", from: "0.12.1"),
5354
],
5455
targets: [
5556
// MARK: - Main
@@ -182,6 +183,7 @@ let package = Package(
182183
// plugins
183184
"MathChatPlugin",
184185
"SearchChatPlugin",
186+
"ShortcutChatPlugin",
185187

186188
.product(name: "OpenAIService", package: "Tool"),
187189
.product(name: "Preferences", package: "Tool"),
@@ -315,6 +317,16 @@ let package = Package(
315317
],
316318
path: "Sources/ChatPlugins/SearchChatPlugin"
317319
),
320+
321+
.target(
322+
name: "ShortcutChatPlugin",
323+
dependencies: [
324+
"ChatPlugin",
325+
.product(name: "Parsing", package: "swift-parsing"),
326+
.product(name: "Terminal", package: "Tool"),
327+
],
328+
path: "Sources/ChatPlugins/ShortcutChatPlugin"
329+
),
318330
]
319331
)
320332

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
1+
import ChatPlugin
2+
import Environment
3+
import Foundation
4+
import OpenAIService
5+
import Parsing
6+
import Terminal
7+
8+
public actor ShortcutChatPlugin: ChatPlugin {
9+
public static var command: String { "shortcut" }
10+
public nonisolated var name: String { "Shortcut" }
11+
12+
let chatGPTService: any ChatGPTServiceType
13+
var terminal: TerminalType = Terminal()
14+
var isCancelled = false
15+
weak var delegate: ChatPluginDelegate?
16+
17+
public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) {
18+
self.chatGPTService = chatGPTService
19+
self.delegate = delegate
20+
}
21+
22+
public func send(content: String, originalMessage: String) async {
23+
delegate?.pluginDidStart(self)
24+
delegate?.pluginDidStartResponding(self)
25+
26+
defer {
27+
delegate?.pluginDidEndResponding(self)
28+
delegate?.pluginDidEnd(self)
29+
}
30+
31+
let id = "\(Self.command)-\(UUID().uuidString)"
32+
var message = ChatMessage(id: id, role: .assistant, content: "")
33+
34+
var content = content[...]
35+
let firstParenthesisParser = PrefixThrough("(")
36+
let shortcutNameParser = PrefixUpTo(")")
37+
38+
_ = try? firstParenthesisParser.parse(&content)
39+
let shortcutName = try? shortcutNameParser.parse(&content)
40+
_ = try? PrefixThrough(")").parse(&content)
41+
42+
guard let shortcutName, !shortcutName.isEmpty else {
43+
message.content =
44+
"Please provide the shortcut name in format: `/\(Self.command)(shortcut name)`."
45+
await chatGPTService.mutateHistory { history in
46+
history.append(message)
47+
}
48+
return
49+
}
50+
51+
var input = String(content).trimmingCharacters(in: .whitespacesAndNewlines)
52+
if input.isEmpty {
53+
// if no input detected, use the previous message as input
54+
input = await chatGPTService.history.last?.content ?? ""
55+
await chatGPTService.mutateHistory { history in
56+
history.append(.init(role: .user, content: originalMessage))
57+
}
58+
} else {
59+
await chatGPTService.mutateHistory { history in
60+
history.append(.init(role: .user, content: originalMessage))
61+
}
62+
}
63+
64+
do {
65+
if isCancelled { throw CancellationError() }
66+
67+
let env = ProcessInfo.processInfo.environment
68+
let shell = env["SHELL"] ?? "/bin/bash"
69+
let temporaryURL = FileManager.default.temporaryDirectory
70+
let temporaryInputFileURL = temporaryURL
71+
.appendingPathComponent("\(id)-input.txt")
72+
let temporaryOutputFileURL = temporaryURL
73+
.appendingPathComponent("\(id)-output")
74+
75+
try input.write(to: temporaryInputFileURL, atomically: true, encoding: .utf8)
76+
77+
let command = """
78+
shortcuts run "\(shortcutName)" \
79+
-i "\(temporaryInputFileURL.path)" \
80+
-o "\(temporaryOutputFileURL.path)"
81+
"""
82+
83+
_ = try await terminal.runCommand(
84+
shell,
85+
arguments: ["-i", "-l", "-c", command],
86+
currentDirectoryPath: "/",
87+
environment: [:]
88+
)
89+
90+
await Task.yield()
91+
92+
if FileManager.default.fileExists(atPath: temporaryOutputFileURL.path) {
93+
let data = try Data(contentsOf: temporaryOutputFileURL)
94+
if let text = String(data: data, encoding: .utf8) {
95+
message.content = text
96+
if text.isEmpty {
97+
message.content = "Finished"
98+
}
99+
await chatGPTService.mutateHistory { history in
100+
history.append(message)
101+
}
102+
} else {
103+
message.content = """
104+
[View File](\(temporaryOutputFileURL))
105+
"""
106+
await chatGPTService.mutateHistory { history in
107+
history.append(message)
108+
}
109+
}
110+
111+
return
112+
}
113+
114+
message.content = "Finished"
115+
await chatGPTService.mutateHistory { history in
116+
history.append(message)
117+
}
118+
} catch {
119+
message.content = error.localizedDescription
120+
if error.localizedDescription.isEmpty {
121+
message.content = "Error"
122+
}
123+
await chatGPTService.mutateHistory { history in
124+
history.append(message)
125+
}
126+
}
127+
}
128+
129+
public func cancel() async {
130+
isCancelled = true
131+
await terminal.terminate()
132+
}
133+
134+
public func stopResponding() async {
135+
isCancelled = true
136+
await terminal.terminate()
137+
}
138+
}
139+
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,13 @@
11
import ChatPlugin
22
import MathChatPlugin
33
import SearchChatPlugin
4+
import ShortcutChatPlugin
45

56
let allPlugins: [ChatPlugin.Type] = [
67
TerminalChatPlugin.self,
78
AITerminalChatPlugin.self,
89
MathChatPlugin.self,
910
SearchChatPlugin.self,
11+
ShortcutChatPlugin.self,
1012
]
13+

Core/Sources/SuggestionWidget/CustomTextEditor.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ struct CustomTextEditor: NSViewRepresentable {
3030
let textView = (context.coordinator.theTextView.documentView as! NSTextView)
3131
guard textView.string != text else { return }
3232
textView.string = text
33+
textView.undoManager?.removeAllActions()
3334
}
3435
}
3536

Core/Sources/SuggestionWidget/SuggestionPanelContent/ChatPanel.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -362,6 +362,7 @@ struct ChatPanelInputArea: View {
362362
"/airun",
363363
"/math",
364364
"/search",
365+
"/shortcut",
365366
"/exit",
366367
"@selection",
367368
"@file",

README.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -214,12 +214,13 @@ If you need to end a plugin, you can just type
214214
/exit
215215
```
216216

217-
| Command | Description |
218-
| :-------: | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
219-
| `/run` | Runs the command under the project root. You can also use environment variable `PROJECT_ROOT` to get the project root and `FILE_PATH` to get the editing file path. |
220-
| `/airun` | Creates a command with natural language. You can ask to modify the command if it is not what you want. After confirming, the command will be executed by calling the `/run` plugin. |
221-
| `/math` | Solves a math problem in natural language |
222-
| `/search` | Search on Bing and summarize the results. You have to setup the Bing Search API in the host app before using it. |
217+
| Command | Description |
218+
| :------------------------: | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
219+
| `/run` | Runs the command under the project root. You can also use environment variable `PROJECT_ROOT` to get the project root and `FILE_PATH` to get the editing file path. |
220+
| `/airun` | Creates a command with natural language. You can ask to modify the command if it is not what you want. After confirming, the command will be executed by calling the `/run` plugin. |
221+
| `/math` | Solves a math problem in natural language |
222+
| `/search` | Search on Bing and summarize the results. You have to setup the Bing Search API in the host app before using it. |
223+
| `/shortcut(shortcut name)` | Run a shortcut from the Shortcuts.app, and use the following message as the input. If the message is empty, it will use the previous message as input. The output of the shortcut will be printed as a reply from the bot |
223224

224225
### Prompt to Code
225226

0 commit comments

Comments
 (0)