Skip to content

Commit 461f2bc

Browse files
committed
Add custom command settings
1 parent 3089314 commit 461f2bc

File tree

5 files changed

+343
-1
lines changed

5 files changed

+343
-1
lines changed

Copilot for Xcode.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@
5050
C882175A294187E100A22FD3 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = C8821759294187E100A22FD3 /* Client */; };
5151
C882175C294187EF00A22FD3 /* Client in Frameworks */ = {isa = PBXBuildFile; productRef = C882175B294187EF00A22FD3 /* Client */; };
5252
C8C8B60929AFA35F00034BEE /* CopilotForXcodeExtensionService.app in Embed XPCService */ = {isa = PBXBuildFile; fileRef = C861E60E2994F6070056CB02 /* CopilotForXcodeExtensionService.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
53+
C8D737C029F19DDD00EFD3C6 /* CustomCommandView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8D737BF29F19DDD00EFD3C6 /* CustomCommandView.swift */; };
5354
C8DCF00029CE11D500FDDDD7 /* ChatWithSelection.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */; };
5455
C8EE079D29CC21300043B6D9 /* AccountView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8EE079C29CC21300043B6D9 /* AccountView.swift */; };
5556
C8EE079F29CC25C20043B6D9 /* OpenAIView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C8EE079E29CC25C20043B6D9 /* OpenAIView.swift */; };
@@ -199,6 +200,7 @@
199200
C87F3E61293DD004008523E8 /* Styles.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Styles.swift; sourceTree = "<group>"; };
200201
C887BC832965D96000931567 /* DEVELOPMENT.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = DEVELOPMENT.md; sourceTree = "<group>"; };
201202
C8CD828229B88006008D044D /* TestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = TestPlan.xctestplan; sourceTree = "<group>"; };
203+
C8D737BF29F19DDD00EFD3C6 /* CustomCommandView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomCommandView.swift; sourceTree = "<group>"; };
202204
C8DCEFFF29CE11D500FDDDD7 /* ChatWithSelection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatWithSelection.swift; sourceTree = "<group>"; };
203205
C8EE079C29CC21300043B6D9 /* AccountView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountView.swift; sourceTree = "<group>"; };
204206
C8EE079E29CC25C20043B6D9 /* OpenAIView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OpenAIView.swift; sourceTree = "<group>"; };
@@ -320,6 +322,7 @@
320322
C8EE079E29CC25C20043B6D9 /* OpenAIView.swift */,
321323
C83B2B7F293DA1B600C5ACCD /* InstructionView.swift */,
322324
C841BB232994CAD400B0B336 /* SettingsView.swift */,
325+
C8D737BF29F19DDD00EFD3C6 /* CustomCommandView.swift */,
323326
C85AF32C29CF0C170031E18B /* DebugView.swift */,
324327
C87F3E61293DD004008523E8 /* Styles.swift */,
325328
C8189B1D2938973000C9DCDA /* Assets.xcassets */,
@@ -563,6 +566,7 @@
563566
C83B2B80293DA1B600C5ACCD /* InstructionView.swift in Sources */,
564567
C83B2B7C293D9FB400C5ACCD /* CopilotView.swift in Sources */,
565568
C85AF32D29CF0C170031E18B /* DebugView.swift in Sources */,
569+
C8D737C029F19DDD00EFD3C6 /* CustomCommandView.swift in Sources */,
566570
C83B2B7A293D9C8C00C5ACCD /* LaunchAgentManager.swift in Sources */,
567571
C8189B1A2938972F00C9DCDA /* App.swift in Sources */,
568572
);
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
import Preferences
2+
import SwiftUI
3+
4+
struct CustomCommandView: View {
5+
final class Settings: ObservableObject {
6+
@AppStorage(\.customCommands) var customCommands
7+
var illegalNames: [String] {
8+
let existed = customCommands.map(\.name)
9+
let builtin: [String] = [
10+
"Get Suggestions",
11+
"Accept Suggestion",
12+
"Reject Suggestion",
13+
"Next Suggestion",
14+
"Previous Suggestion",
15+
"Toggle Real-time Suggestions",
16+
"Real-time Suggestions",
17+
"Prefetch Suggestions",
18+
"Chat with Selection",
19+
"Prompt to Code",
20+
"# Custom Commands:",
21+
]
22+
23+
return existed + builtin
24+
}
25+
26+
init(customCommands: AppStorage<[CustomCommand]>? = nil) {
27+
if let list = customCommands {
28+
_customCommands = list
29+
}
30+
}
31+
}
32+
33+
struct EditingCommand {
34+
var isNew: Bool
35+
var command: CustomCommand
36+
}
37+
38+
var isOpen: Binding<Bool>
39+
@State var editingCommand: EditingCommand?
40+
var isEditPanelPresented: Binding<Bool> {
41+
.init(
42+
get: { editingCommand != nil },
43+
set: { newValue in
44+
if !newValue {
45+
editingCommand = nil
46+
}
47+
}
48+
)
49+
}
50+
51+
@StateObject var settings = Settings()
52+
53+
var body: some View {
54+
VStack {
55+
HStack {
56+
Button(action: {
57+
self.isOpen.wrappedValue = false
58+
}) {
59+
Image(systemName: "xmark.circle.fill")
60+
.foregroundStyle(.secondary)
61+
.padding()
62+
}
63+
.buttonStyle(.plain)
64+
Text("Custom Commands")
65+
Spacer()
66+
Button(action: {
67+
editingCommand = .init(isNew: true, command: CustomCommand(
68+
name: "New Command",
69+
feature: .chatWithSelection(prompt: "Tell me about the code.")
70+
))
71+
}) {
72+
Image(systemName: "plus.circle.fill")
73+
.foregroundStyle(.secondary)
74+
.padding()
75+
}
76+
.buttonStyle(.plain)
77+
}
78+
.background(.black.opacity(0.2))
79+
80+
List {
81+
ForEach(settings.customCommands, id: \.name) { command in
82+
HStack {
83+
Text(command.name)
84+
85+
Spacer()
86+
87+
Group {
88+
switch command.feature {
89+
case .chatWithSelection:
90+
Text("Chat with Selection")
91+
case .customChat:
92+
Text("Custom Chat")
93+
case .promptToCode:
94+
Text("Prompt to Code")
95+
}
96+
}
97+
.foregroundStyle(.tertiary)
98+
}
99+
.contentShape(Rectangle())
100+
.onTapGesture {
101+
editingCommand = .init(isNew: false, command: command)
102+
}
103+
.contextMenu {
104+
Button("Remove") {
105+
settings.customCommands.removeAll(
106+
where: { $0.name == command.name }
107+
)
108+
}
109+
}
110+
}
111+
}
112+
.overlay {
113+
if settings.customCommands.isEmpty {
114+
Text("""
115+
Empty
116+
Add command with "+" button
117+
""")
118+
.multilineTextAlignment(.center)
119+
}
120+
}
121+
}
122+
.frame(width: 500, height: 500)
123+
.sheet(isPresented: isEditPanelPresented) {
124+
EditCustomCommandView(
125+
editingCommand: $editingCommand,
126+
settings: settings
127+
)
128+
}
129+
}
130+
}
131+
132+
struct EditCustomCommandView: View {
133+
@Binding var editingCommand: CustomCommandView.EditingCommand?
134+
var settings: CustomCommandView.Settings
135+
let originalName: String
136+
@State var commandType: CommandType
137+
138+
@State var name: String
139+
@State var prompt: String
140+
@State var systemPrompt: String
141+
@State var continuousMode: Bool
142+
@State var errorMessage: String?
143+
144+
enum CommandType: Int, CaseIterable {
145+
case chatWithSelection
146+
case promptToCode
147+
case customChat
148+
}
149+
150+
init(
151+
editingCommand: Binding<CustomCommandView.EditingCommand?>,
152+
settings: CustomCommandView.Settings
153+
) {
154+
_editingCommand = editingCommand
155+
self.settings = settings
156+
originalName = editingCommand.wrappedValue?.command.name ?? ""
157+
name = originalName
158+
switch editingCommand.wrappedValue?.command.feature {
159+
case let .chatWithSelection(prompt):
160+
commandType = .chatWithSelection
161+
self.prompt = prompt ?? ""
162+
self.systemPrompt = ""
163+
self.continuousMode = false
164+
case let .customChat(systemPrompt, prompt):
165+
commandType = .customChat
166+
self.systemPrompt = systemPrompt ?? ""
167+
self.prompt = prompt ?? ""
168+
self.continuousMode = false
169+
case let .promptToCode(prompt, continuousMode):
170+
commandType = .promptToCode
171+
self.prompt = prompt ?? ""
172+
self.systemPrompt = ""
173+
self.continuousMode = continuousMode ?? false
174+
case .none:
175+
commandType = .chatWithSelection
176+
self.prompt = ""
177+
self.systemPrompt = ""
178+
self.continuousMode = false
179+
}
180+
}
181+
182+
var body: some View {
183+
VStack {
184+
Form {
185+
TextField("Name", text: $name)
186+
187+
Picker("Command Type", selection: $commandType) {
188+
ForEach(CommandType.allCases, id: \.rawValue) { commandType in
189+
Text({
190+
switch commandType {
191+
case .chatWithSelection:
192+
return "Chat with Selection"
193+
case .promptToCode:
194+
return "Prompt to Code"
195+
case .customChat:
196+
return "Custom Chat"
197+
}
198+
}() as String).tag(commandType)
199+
}
200+
}
201+
202+
switch commandType {
203+
case .chatWithSelection:
204+
promptTextField
205+
case .promptToCode:
206+
promptTextField
207+
continuousModeToggle
208+
case .customChat:
209+
systemPromptTextField
210+
promptTextField
211+
}
212+
}
213+
214+
Text(
215+
"After renaming or adding a custom command, please restart Xcode to refresh the menu."
216+
)
217+
.foregroundStyle(.secondary)
218+
.padding()
219+
220+
HStack {
221+
Spacer()
222+
Button("Cancel") {
223+
editingCommand = nil
224+
}
225+
226+
lazy var newCommand = CustomCommand(
227+
name: name,
228+
feature: {
229+
switch commandType {
230+
case .chatWithSelection:
231+
return .chatWithSelection(prompt: prompt)
232+
case .promptToCode:
233+
return .promptToCode(prompt: prompt, continuousMode: continuousMode)
234+
case .customChat:
235+
return .customChat(systemPrompt: systemPrompt, prompt: prompt)
236+
}
237+
}()
238+
)
239+
240+
if editingCommand?.isNew ?? true {
241+
Button("Add") {
242+
guard !settings.illegalNames.contains(newCommand.name) else {
243+
errorMessage = "Command name is illegal."
244+
return
245+
}
246+
guard !newCommand.name.isEmpty else {
247+
errorMessage = "Command name cannot be empty."
248+
return
249+
}
250+
settings.customCommands.append(newCommand)
251+
editingCommand = nil
252+
}
253+
} else {
254+
Button("Update") {
255+
guard let command = editingCommand?.command else { return }
256+
guard !settings.illegalNames.contains(newCommand.name)
257+
|| newCommand.name == originalName
258+
else {
259+
errorMessage = "Command name is illegal."
260+
return
261+
}
262+
guard !newCommand.name.isEmpty else {
263+
errorMessage = "Command name cannot be empty."
264+
return
265+
}
266+
267+
if let index = settings.customCommands.firstIndex(where: {
268+
$0.name == originalName
269+
}) {
270+
settings.customCommands[index] = newCommand
271+
} else {
272+
settings.customCommands.append(newCommand)
273+
}
274+
editingCommand = nil
275+
}
276+
}
277+
}.buttonStyle(.copilot)
278+
279+
if let errorMessage {
280+
Text(errorMessage).foregroundColor(.red)
281+
}
282+
}
283+
.padding()
284+
.frame(minWidth: 500)
285+
}
286+
287+
var promptTextField: some View {
288+
TextField("Prompt", text: $prompt)
289+
.lineLimit(0)
290+
}
291+
292+
var systemPromptTextField: some View {
293+
TextField("System Prompt", text: $systemPrompt)
294+
.lineLimit(0)
295+
}
296+
297+
var continuousModeToggle: some View {
298+
Toggle("Continuous Mode", isOn: $continuousMode)
299+
}
300+
}
301+
302+
// MARK: - Previews
303+
304+
struct CustomCommandView_Preview: PreviewProvider {
305+
static var previews: some View {
306+
CustomCommandView(
307+
isOpen: .constant(true),
308+
settings: .init(customCommands: .init(wrappedValue: [
309+
.init(name: "Explain Code", feature: .chatWithSelection(prompt: "Hello")),
310+
.init(
311+
name: "Refactor Code",
312+
feature: .promptToCode(prompt: "Refactor", continuousMode: false)
313+
),
314+
.init(
315+
name: "Tell Me A Joke",
316+
feature: .customChat(systemPrompt: "Joke", prompt: "")
317+
),
318+
], "CustomCommandView_Preview"))
319+
)
320+
.background(.purple)
321+
}
322+
}

Copilot for Xcode/SettingsView.swift

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,20 @@ struct SettingsView: View {
3636
.value(for: \.realtimeSuggestionDebounce)
3737
@Environment(\.updateChecker) var updateChecker
3838
@State var isSuggestionFeatureEnabledListPickerOpen = false
39+
@State var isCustomCommandEditorOpen = false
3940

4041
var body: some View {
4142
Section {
43+
Button("Edit Custom Commands") {
44+
isCustomCommandEditorOpen = true
45+
}
46+
.buttonStyle(.copilot)
47+
.sheet(isPresented: $isCustomCommandEditorOpen) {
48+
CustomCommandView(
49+
isOpen: $isCustomCommandEditorOpen
50+
)
51+
}
52+
4253
Form {
4354
Toggle(isOn: $settings.quitXPCServiceOnXcodeAndAppQuit) {
4455
Text("Quit service when Xcode and host app are terminated")

Core/Sources/Preferences/CustomCommand.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,4 +12,9 @@ public struct CustomCommand: Codable {
1212

1313
public var name: String
1414
public var feature: Feature
15+
16+
public init(name: String, feature: Feature) {
17+
self.name = name
18+
self.feature = feature
19+
}
1520
}

Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -408,7 +408,7 @@ extension WindowBaseCommandHandler {
408408
}
409409
}
410410

411-
if let sendingMessageImmediately {
411+
if let sendingMessageImmediately, !sendingMessageImmediately.isEmpty {
412412
try await chat.send(content: sendingMessageImmediately)
413413
}
414414
}

0 commit comments

Comments
 (0)