diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode Debug.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode Debug.xcscheme
new file mode 100644
index 00000000..70ab5d8d
--- /dev/null
+++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/Copilot for Xcode Debug.xcscheme
@@ -0,0 +1,89 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme
index eef52c11..b72db8dd 100644
--- a/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme
+++ b/Copilot for Xcode.xcodeproj/xcshareddata/xcschemes/ExtensionService.xcscheme
@@ -46,9 +46,6 @@
reference = "container:TestPlan.xctestplan"
default = "YES">
-
-
DisplayedChatMessage.Reference {
+ .init(
+ title: reference.title,
+ subtitle: {
+ switch reference.kind {
+ case let .symbol(_, uri, _, _):
+ return uri
+ case let .webpage(uri):
+ return uri
+ case let .textFile(uri):
+ return uri
+ case let .other(kind):
+ return kind
+ case .text:
+ return reference.content
+ }
+ }(),
+ uri: {
+ switch reference.kind {
+ case let .symbol(_, uri, _, _):
+ return uri
+ case let .webpage(uri):
+ return uri
+ case let .textFile(uri):
+ return uri
+ case .other:
+ return ""
+ case .text:
+ return ""
+ }
+ }(),
+ startLine: {
+ switch reference.kind {
+ case let .symbol(_, _, startLine, _):
+ return startLine
+ default:
+ return nil
+ }
+ }(),
+ kind: reference.kind
+ )
+}
+
diff --git a/Core/Sources/ChatGPTChatTab/ChatPanel.swift b/Core/Sources/ChatGPTChatTab/ChatPanel.swift
index 139ed7ab..9210a05d 100644
--- a/Core/Sources/ChatGPTChatTab/ChatPanel.swift
+++ b/Core/Sources/ChatGPTChatTab/ChatPanel.swift
@@ -504,7 +504,7 @@ struct ChatPanel_Preview: PreviewProvider {
subtitle: "Hi Hi Hi Hi",
uri: "https://google.com",
startLine: nil,
- kind: .class
+ kind: .symbol(.class, uri: "https://google.com", startLine: nil, endLine: nil)
),
]
),
diff --git a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
index 2605d6d5..09bcd8e8 100644
--- a/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
+++ b/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift
@@ -139,32 +139,37 @@ struct ReferenceIcon: View {
RoundedRectangle(cornerRadius: 4)
.fill({
switch kind {
- case .class:
- Color.purple
- case .struct:
- Color.purple
- case .enum:
- Color.purple
- case .actor:
- Color.purple
- case .protocol:
- Color.purple
- case .extension:
- Color.indigo
- case .case:
- Color.green
- case .property:
- Color.teal
- case .typealias:
- Color.orange
- case .function:
- Color.teal
- case .method:
- Color.blue
+ case .symbol(let symbol, _, _, _):
+ switch symbol {
+ case .class:
+ Color.purple
+ case .struct:
+ Color.purple
+ case .enum:
+ Color.purple
+ case .actor:
+ Color.purple
+ case .protocol:
+ Color.purple
+ case .extension:
+ Color.indigo
+ case .case:
+ Color.green
+ case .property:
+ Color.teal
+ case .typealias:
+ Color.orange
+ case .function:
+ Color.teal
+ case .method:
+ Color.blue
+ }
case .text:
Color.gray
case .webpage:
Color.blue
+ case .textFile:
+ Color.gray
case .other:
Color.gray
}
@@ -173,34 +178,39 @@ struct ReferenceIcon: View {
.overlay(alignment: .center) {
Group {
switch kind {
- case .class:
- Text("C")
- case .struct:
- Text("S")
- case .enum:
- Text("E")
- case .actor:
- Text("A")
- case .protocol:
- Text("Pr")
- case .extension:
- Text("Ex")
- case .case:
- Text("K")
- case .property:
- Text("P")
- case .typealias:
- Text("T")
- case .function:
- Text("𝑓")
- case .method:
- Text("M")
+ case .symbol(let symbol, _, _, _):
+ switch symbol {
+ case .class:
+ Text("C")
+ case .struct:
+ Text("S")
+ case .enum:
+ Text("E")
+ case .actor:
+ Text("A")
+ case .protocol:
+ Text("Pr")
+ case .extension:
+ Text("Ex")
+ case .case:
+ Text("K")
+ case .property:
+ Text("P")
+ case .typealias:
+ Text("T")
+ case .function:
+ Text("𝑓")
+ case .method:
+ Text("M")
+ }
case .text:
Text("Tx")
case .webpage:
Text("Wb")
case .other:
Text("Ot")
+ case .textFile:
+ Text("Tx")
}
}
.font(.system(size: 12).monospaced())
@@ -225,7 +235,7 @@ struct ReferenceIcon: View {
subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100",
uri: "https://google.com",
startLine: nil,
- kind: .class
+ kind: .symbol(.class, uri: "https://google.com", startLine: nil, endLine: nil)
), count: 20),
chat: .init(initialState: .init(), reducer: { Chat(service: .init()) })
)
@@ -240,43 +250,42 @@ struct ReferenceIcon: View {
subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100",
uri: "https://google.com",
startLine: nil,
- kind: .class
+ kind: .symbol(.class, uri: "https://google.com", startLine: nil, endLine: nil)
),
.init(
title: "BotMessage.swift:100-102",
subtitle: "/Core/Sources/ChatGPTChatTab/Views",
uri: "https://google.com",
startLine: nil,
- kind: .struct
+ kind: .symbol(.struct, uri: "https://google.com", startLine: nil, endLine: nil)
),
.init(
title: "ReferenceList",
subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100",
uri: "https://google.com",
startLine: nil,
- kind: .function
+ kind: .symbol(.function, uri: "https://google.com", startLine: nil, endLine: nil)
),
.init(
title: "ReferenceList",
subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100",
uri: "https://google.com",
startLine: nil,
- kind: .case
+ kind: .symbol(.case, uri: "https://google.com", startLine: nil, endLine: nil)
),
.init(
title: "ReferenceList",
subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100",
uri: "https://google.com",
startLine: nil,
- kind: .extension
+ kind: .symbol(.extension, uri: "https://google.com", startLine: nil, endLine: nil)
),
.init(
title: "ReferenceList",
subtitle: "/Core/Sources/ChatGPTChatTab/Views/BotMessage.swift:100",
uri: "https://google.com",
startLine: nil,
- kind: .webpage
+ kind: .webpage(uri: "https://google.com")
),
], chat: .init(initialState: .init(), reducer: { Chat(service: .init()) }))
}
-
diff --git a/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift
index 15da1780..1efa86f5 100644
--- a/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift
+++ b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift
@@ -15,9 +15,9 @@ struct ThemedMarkdownText: View {
let content: MarkdownContent
init(_ text: String) {
- self.content = .init(text)
+ content = .init(text)
}
-
+
init(_ content: MarkdownContent) {
self.content = content
}
@@ -71,6 +71,8 @@ extension MarkdownUI.Theme {
}
.codeBlock { configuration in
let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock)
+ || ["plaintext", "text", "markdown", "sh", "bash", "shell", "latex", "tex"]
+ .contains(configuration.language)
if wrapCode {
AsyncCodeBlockView(
diff --git a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift b/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift
index 6e95f29d..d00990f4 100644
--- a/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift
+++ b/Core/Sources/ChatPlugin/AITerminalChatPlugin.swift
@@ -6,14 +6,14 @@ public actor AITerminalChatPlugin: ChatPlugin {
public static var command: String { "airun" }
public nonisolated var name: String { "AI Terminal" }
- let chatGPTService: any ChatGPTServiceType
+ let chatGPTService: any LegacyChatGPTServiceType
var terminal: TerminalType = Terminal()
var isCancelled = false
weak var delegate: ChatPluginDelegate?
var isStarted = false
var command: String?
- public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) {
+ public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) {
self.chatGPTService = chatGPTService
self.delegate = delegate
}
diff --git a/Core/Sources/ChatPlugin/AskChatGPT.swift b/Core/Sources/ChatPlugin/AskChatGPT.swift
index e95deac9..b942a7de 100644
--- a/Core/Sources/ChatPlugin/AskChatGPT.swift
+++ b/Core/Sources/ChatPlugin/AskChatGPT.swift
@@ -12,9 +12,10 @@ public func askChatGPT(
let memory = AutoManagedChatGPTMemory(
systemPrompt: systemPrompt,
configuration: configuration,
- functionProvider: NoChatGPTFunctionProvider()
+ functionProvider: NoChatGPTFunctionProvider(),
+ maxNumberOfMessages: .max
)
- let service = ChatGPTService(
+ let service = LegacyChatGPTService(
memory: memory,
configuration: configuration
)
diff --git a/Core/Sources/ChatPlugin/CallAIFunction.swift b/Core/Sources/ChatPlugin/CallAIFunction.swift
index e29a4d31..20f7a01d 100644
--- a/Core/Sources/ChatPlugin/CallAIFunction.swift
+++ b/Core/Sources/ChatPlugin/CallAIFunction.swift
@@ -18,11 +18,12 @@ func callAIFunction(
let argsString = args.joined(separator: ", ")
let configuration = UserPreferenceChatGPTConfiguration()
.overriding(.init(temperature: 0))
- let service = ChatGPTService(
+ let service = LegacyChatGPTService(
memory: AutoManagedChatGPTMemory(
systemPrompt: "You are now the following python function: ```# \(description)\n\(function)```\n\nOnly respond with your `return` value.",
configuration: configuration,
- functionProvider: NoChatGPTFunctionProvider()
+ functionProvider: NoChatGPTFunctionProvider(),
+ maxNumberOfMessages: .max
),
configuration: configuration
)
diff --git a/Core/Sources/ChatPlugin/ChatPlugin.swift b/Core/Sources/ChatPlugin/ChatPlugin.swift
index 770b0852..d978e265 100644
--- a/Core/Sources/ChatPlugin/ChatPlugin.swift
+++ b/Core/Sources/ChatPlugin/ChatPlugin.swift
@@ -6,7 +6,7 @@ public protocol ChatPlugin: AnyObject {
static var command: String { get }
var name: String { get }
- init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate)
+ init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate)
func send(content: String, originalMessage: String) async
func cancel() async
func stopResponding() async
diff --git a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift
index 285b2947..9c975bbc 100644
--- a/Core/Sources/ChatPlugin/TerminalChatPlugin.swift
+++ b/Core/Sources/ChatPlugin/TerminalChatPlugin.swift
@@ -7,12 +7,12 @@ public actor TerminalChatPlugin: ChatPlugin {
public static var command: String { "run" }
public nonisolated var name: String { "Terminal" }
- let chatGPTService: any ChatGPTServiceType
+ let chatGPTService: any LegacyChatGPTServiceType
var terminal: TerminalType = Terminal()
var isCancelled = false
weak var delegate: ChatPluginDelegate?
- public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) {
+ public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) {
self.chatGPTService = chatGPTService
self.delegate = delegate
}
diff --git a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift
index 2bfa3846..67e5720f 100644
--- a/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift
+++ b/Core/Sources/ChatPlugins/MathChatPlugin/MathChatPlugin.swift
@@ -7,11 +7,11 @@ public actor MathChatPlugin: ChatPlugin {
public static var command: String { "math" }
public nonisolated var name: String { "Math" }
- let chatGPTService: any ChatGPTServiceType
+ let chatGPTService: any LegacyChatGPTServiceType
var isCancelled = false
weak var delegate: ChatPluginDelegate?
- public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) {
+ public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) {
self.chatGPTService = chatGPTService
self.delegate = delegate
}
diff --git a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift
index 99cf6028..1b05168f 100644
--- a/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift
+++ b/Core/Sources/ChatPlugins/SearchChatPlugin/SearchChatPlugin.swift
@@ -6,11 +6,11 @@ public actor SearchChatPlugin: ChatPlugin {
public static var command: String { "search" }
public nonisolated var name: String { "Search" }
- let chatGPTService: any ChatGPTServiceType
+ let chatGPTService: any LegacyChatGPTServiceType
var isCancelled = false
weak var delegate: ChatPluginDelegate?
- public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) {
+ public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) {
self.chatGPTService = chatGPTService
self.delegate = delegate
}
diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift
index c6a9bddf..23eb75ec 100644
--- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift
+++ b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutChatPlugin.swift
@@ -8,12 +8,12 @@ public actor ShortcutChatPlugin: ChatPlugin {
public static var command: String { "shortcut" }
public nonisolated var name: String { "Shortcut" }
- let chatGPTService: any ChatGPTServiceType
+ let chatGPTService: any LegacyChatGPTServiceType
var terminal: TerminalType = Terminal()
var isCancelled = false
weak var delegate: ChatPluginDelegate?
- public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) {
+ public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) {
self.chatGPTService = chatGPTService
self.delegate = delegate
}
diff --git a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift
index 5616f072..8eab91ff 100644
--- a/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift
+++ b/Core/Sources/ChatPlugins/ShortcutChatPlugin/ShortcutInputChatPlugin.swift
@@ -8,12 +8,12 @@ public actor ShortcutInputChatPlugin: ChatPlugin {
public static var command: String { "shortcutInput" }
public nonisolated var name: String { "Shortcut Input" }
- let chatGPTService: any ChatGPTServiceType
+ let chatGPTService: any LegacyChatGPTServiceType
var terminal: TerminalType = Terminal()
var isCancelled = false
weak var delegate: ChatPluginDelegate?
- public init(inside chatGPTService: any ChatGPTServiceType, delegate: ChatPluginDelegate) {
+ public init(inside chatGPTService: any LegacyChatGPTServiceType, delegate: ChatPluginDelegate) {
self.chatGPTService = chatGPTService
self.delegate = delegate
}
diff --git a/Core/Sources/ChatService/ChatPluginController.swift b/Core/Sources/ChatService/ChatPluginController.swift
index 99a7c629..82a35662 100644
--- a/Core/Sources/ChatService/ChatPluginController.swift
+++ b/Core/Sources/ChatService/ChatPluginController.swift
@@ -4,12 +4,12 @@ import Foundation
import OpenAIService
final class ChatPluginController {
- let chatGPTService: any ChatGPTServiceType
+ let chatGPTService: any LegacyChatGPTServiceType
let plugins: [String: ChatPlugin.Type]
var runningPlugin: ChatPlugin?
weak var chatService: ChatService?
- init(chatGPTService: any ChatGPTServiceType, plugins: [ChatPlugin.Type]) {
+ init(chatGPTService: any LegacyChatGPTServiceType, plugins: [ChatPlugin.Type]) {
self.chatGPTService = chatGPTService
var all = [String: ChatPlugin.Type]()
for plugin in plugins {
@@ -18,7 +18,7 @@ final class ChatPluginController {
self.plugins = all
}
- convenience init(chatGPTService: any ChatGPTServiceType, plugins: ChatPlugin.Type...) {
+ convenience init(chatGPTService: any LegacyChatGPTServiceType, plugins: ChatPlugin.Type...) {
self.init(chatGPTService: chatGPTService, plugins: plugins)
}
diff --git a/Core/Sources/ChatService/ChatService.swift b/Core/Sources/ChatService/ChatService.swift
index 4bb74639..029fface 100644
--- a/Core/Sources/ChatService/ChatService.swift
+++ b/Core/Sources/ChatService/ChatService.swift
@@ -10,7 +10,7 @@ public final class ChatService: ObservableObject {
public let memory: ContextAwareAutoManagedChatGPTMemory
public let configuration: OverridingChatGPTConfiguration
- public let chatGPTService: any ChatGPTServiceType
+ public let chatGPTService: any LegacyChatGPTServiceType
public var allPluginCommands: [String] { allPlugins.map { $0.command } }
@Published public internal(set) var chatHistory: [ChatMessage] = []
@Published public internal(set) var isReceivingMessage = false
@@ -22,7 +22,7 @@ public final class ChatService: ObservableObject {
let pluginController: ChatPluginController
var cancellable = Set()
- init(
+ init(
memory: ContextAwareAutoManagedChatGPTMemory,
configuration: OverridingChatGPTConfiguration,
chatGPTService: T
@@ -53,7 +53,7 @@ public final class ChatService: ObservableObject {
self.init(
memory: memory,
configuration: configuration,
- chatGPTService: ChatGPTService(
+ chatGPTService: LegacyChatGPTService(
memory: memory,
configuration: extraConfiguration,
functionProvider: memory.functionProvider
diff --git a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift
index ac44d87c..32d65694 100644
--- a/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift
+++ b/Core/Sources/ChatService/ContextAwareAutoManagedChatGPTMemory.swift
@@ -22,7 +22,8 @@ public final class ContextAwareAutoManagedChatGPTMemory: ChatGPTMemory {
memory = AutoManagedChatGPTMemory(
systemPrompt: "",
configuration: configuration,
- functionProvider: functionProvider
+ functionProvider: functionProvider,
+ maxNumberOfMessages: UserDefaults.shared.value(for: \.chatGPTMaxMessageCount)
)
contextController = DynamicContextController(
memory: memory,
diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
index 7450105e..0f5ec8f8 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift
@@ -29,6 +29,8 @@ struct ChatModelEdit {
var apiKeySelection: APIKeySelection.State = .init()
var baseURLSelection: BaseURLSelection.State = .init()
var enforceMessageOrder: Bool = false
+ var openAIOrganizationID: String = ""
+ var openAIProjectID: String = ""
}
enum Action: Equatable, BindableAction {
@@ -85,7 +87,7 @@ struct ChatModelEdit {
let model = ChatModel(state: state)
return .run { send in
do {
- let service = ChatGPTService(
+ let service = LegacyChatGPTService(
configuration: UserPreferenceChatGPTConfiguration()
.overriding {
$0.model = model
@@ -197,6 +199,10 @@ extension ChatModel {
}
}(),
modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines),
+ openAIInfo: .init(
+ organizationID: state.openAIOrganizationID,
+ projectID: state.openAIProjectID
+ ),
ollamaInfo: .init(keepAlive: state.ollamaKeepAlive),
googleGenerativeAIInfo: .init(apiVersion: state.apiVersion),
openAICompatibleInfo: .init(enforceMessageOrder: state.enforceMessageOrder)
@@ -219,7 +225,9 @@ extension ChatModel {
apiKeyManagement: .init(availableAPIKeyNames: [info.apiKeyName])
),
baseURLSelection: .init(baseURL: info.baseURL, isFullURL: info.isFullURL),
- enforceMessageOrder: info.openAICompatibleInfo.enforceMessageOrder
+ enforceMessageOrder: info.openAICompatibleInfo.enforceMessageOrder,
+ openAIOrganizationID: info.openAIInfo.organizationID,
+ openAIProjectID: info.openAIInfo.projectID
)
}
}
diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
index 1eee9725..77605eac 100644
--- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
+++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift
@@ -243,6 +243,14 @@ struct ChatModelEditView: View {
MaxTokensTextField(store: store)
SupportsFunctionCallingToggle(store: store)
+
+ TextField(text: $store.openAIOrganizationID, prompt: Text("Optional")) {
+ Text("Organization ID")
+ }
+
+ TextField(text: $store.openAIProjectID, prompt: Text("Optional")) {
+ Text("Project ID")
+ }
VStack(alignment: .leading, spacing: 8) {
Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
@@ -308,7 +316,7 @@ struct ChatModelEditView: View {
MaxTokensTextField(store: store)
SupportsFunctionCallingToggle(store: store)
-
+
Toggle(isOn: $store.enforceMessageOrder) {
Text("Enforce message order to be user/assistant alternated")
}
@@ -387,9 +395,9 @@ struct ChatModelEditView: View {
BaseURLTextField(store: store, prompt: Text("https://api.anthropic.com")) {
Text("/v1/messages")
}
-
+
ApiKeyNamePicker(store: store)
-
+
TextField("Model Name", text: $store.modelName)
.overlay(alignment: .trailing) {
Picker(
@@ -411,9 +419,9 @@ struct ChatModelEditView: View {
)
.frame(width: 20)
}
-
+
MaxTokensTextField(store: store)
-
+
VStack(alignment: .leading, spacing: 8) {
Text(Image(systemName: "exclamationmark.triangle.fill")) + Text(
" For more details, please visit [https://anthropic.com](https://anthropic.com)."
diff --git a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
index 13f37404..53f2d99c 100644
--- a/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
+++ b/Core/Sources/HostApp/CustomCommandSettings/CustomCommandView.swift
@@ -149,7 +149,7 @@ struct CustomCommandView: View {
case .customChat:
Text("Custom Chat")
case .promptToCode:
- Text("Prompt to Code")
+ Text("Modification")
case .singleRoundDialog:
Text("Single Round Dialog")
}
@@ -201,7 +201,7 @@ struct CustomCommandView: View {
"This command sends a message to the active chat tab. You can provide additional context through the \"Extra System Prompt\" as well."
)
}
- SubSection(title: Text("Prompt to Code")) {
+ SubSection(title: Text("Modification")) {
Text(
"This command opens the prompt-to-code panel and executes the provided requirements on the selected code. You can provide additional context through the \"Extra Context\" as well."
)
diff --git a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift
index c2a0f5c0..1dade9bb 100644
--- a/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift
+++ b/Core/Sources/HostApp/CustomCommandSettings/EditCustomCommandView.swift
@@ -37,7 +37,7 @@ struct EditCustomCommandView: View {
case .sendMessage:
return "Send Message"
case .promptToCode:
- return "Prompt to Code"
+ return "Modification"
case .customChat:
return "Custom Chat"
case .singleRoundDialog:
diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsCheatsheetSectionView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsCheatsheetSectionView.swift
deleted file mode 100644
index d0da53a1..00000000
--- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsCheatsheetSectionView.swift
+++ /dev/null
@@ -1,71 +0,0 @@
-import Client
-import Preferences
-import SharedUIComponents
-import SwiftUI
-import XPCShared
-
-#if canImport(ProHostApp)
-import ProHostApp
-#endif
-
-struct SuggestionSettingsCheatsheetSectionView: View {
- final class Settings: ObservableObject {
- @AppStorage(\.isSuggestionSenseEnabled)
- var isSuggestionSenseEnabled
- @AppStorage(\.isSuggestionTypeInTheMiddleEnabled)
- var isSuggestionTypeInTheMiddleEnabled
- }
-
- @StateObject var settings = Settings()
-
- var body: some View {
- #if canImport(ProHostApp)
- SubSection(
- title: Text("Suggestion Sense (Experimental)"),
- description: Text("""
- This cheatsheet will try to improve the suggestion by inserting relevant symbol \
- interfaces in the editing scope to the prompt.
-
- Some suggestion services may have their own RAG system with a higher priority.
- """)
- ) {
- Form {
- WithFeatureEnabled(\.suggestionSense) {
- Toggle(isOn: $settings.isSuggestionSenseEnabled) {
- Text("Enable suggestion sense")
- }
- }
- }
- }
-
- SubSection(
- title: Text("Type-in-the-Middle Hack"),
- description: Text("""
- Suggestion service don't always handle the case where the text cursor is in the middle \
- of a line. This cheatsheet will try to trick the suggestion service to also generate \
- suggestions in these cases.
-
- It can be useful in the following cases:
- - Fixing a typo in the middle of a line.
- - Getting suggestions from a line with Xcode placeholders.
- - and more...
- """)
- ) {
- Form {
- Toggle(isOn: $settings.isSuggestionTypeInTheMiddleEnabled) {
- Text("Enable type-in-the-middle hack")
- }
- }
- }
-
- #else
- Text("Not Available")
- #endif
- }
-}
-
-#Preview {
- SuggestionSettingsCheatsheetSectionView()
- .padding()
-}
-
diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift
index 7cc461bb..632769a4 100644
--- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift
+++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionSettingsView.swift
@@ -4,14 +4,14 @@ import SharedUIComponents
import SwiftUI
import XPCShared
-#if canImport(ProHostApp)
-import ProHostApp
-#endif
-
struct SuggestionSettingsView: View {
- enum Tab {
+ var tabContainer: ExternalTabContainer {
+ ExternalTabContainer.tabContainer(for: "SuggestionSettings")
+ }
+
+ enum Tab: Hashable {
case general
- case suggestionCheatsheet
+ case other(String)
}
@State var tabSelection: Tab = .general
@@ -20,7 +20,9 @@ struct SuggestionSettingsView: View {
VStack(spacing: 0) {
Picker("", selection: $tabSelection) {
Text("General").tag(Tab.general)
- Text("Cheatsheet").tag(Tab.suggestionCheatsheet)
+ ForEach(tabContainer.tabs, id: \.id) { tab in
+ Text(tab.title).tag(Tab.other(tab.id))
+ }
}
.pickerStyle(.segmented)
.padding(8)
@@ -33,8 +35,8 @@ struct SuggestionSettingsView: View {
switch tabSelection {
case .general:
SuggestionSettingsGeneralSectionView()
- case .suggestionCheatsheet:
- SuggestionSettingsCheatsheetSectionView()
+ case let .other(id):
+ tabContainer.tabView(for: id)
}
}.padding()
}
diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift
index ed0f186b..4df4f10c 100644
--- a/Core/Sources/HostApp/FeatureSettingsView.swift
+++ b/Core/Sources/HostApp/FeatureSettingsView.swift
@@ -26,8 +26,8 @@ struct FeatureSettingsView: View {
}
.sidebarItem(
tag: 2,
- title: "Prompt to Code",
- subtitle: "Write code with natural language",
+ title: "Modification",
+ subtitle: "Write or modify code with natural language",
image: "paintbrush"
)
diff --git a/Core/Sources/HostApp/General.swift b/Core/Sources/HostApp/General.swift
index 0dca4505..96ade16c 100644
--- a/Core/Sources/HostApp/General.swift
+++ b/Core/Sources/HostApp/General.swift
@@ -12,49 +12,196 @@ struct General {
var xpcServiceVersion: String?
var isAccessibilityPermissionGranted: Bool?
var isReloading = false
+ @Presents var alert: AlertState?
}
- enum Action: Equatable {
+ enum Action {
case appear
case setupLaunchAgentIfNeeded
+ case setupLaunchAgentClicked
+ case removeLaunchAgentClicked
+ case reloadLaunchAgentClicked
case openExtensionManager
case reloadStatus
case finishReloading(xpcServiceVersion: String, permissionGranted: Bool)
case failedReloading
+ case alert(PresentationAction)
+
+ case setupLaunchAgent
+ case finishSetupLaunchAgent
+ case finishRemoveLaunchAgent
+ case finishReloadLaunchAgent
+
+ @CasePathable
+ enum Alert: Equatable {
+ case moveToApplications
+ case moveTo(URL)
+ case install
+ }
}
@Dependency(\.toast) var toast
-
+
struct ReloadStatusCancellableId: Hashable {}
+ static var didWarnInstallationPosition: Bool {
+ get { UserDefaults.standard.bool(forKey: "didWarnInstallationPosition") }
+ set { UserDefaults.standard.set(newValue, forKey: "didWarnInstallationPosition") }
+ }
+
+ static var bundleIsInApplicationsFolder: Bool {
+ Bundle.main.bundleURL.path.hasPrefix("/Applications")
+ }
+
var body: some ReducerOf {
Reduce { state, action in
switch action {
case .appear:
- return .run { send in
- if UserDefaults.shared.value(for: \.doNotInstallLaunchAgentAutomatically) {
- return
+ if Self.bundleIsInApplicationsFolder {
+ return .run { send in
+ await send(.setupLaunchAgentIfNeeded)
}
- await send(.setupLaunchAgentIfNeeded)
}
+ if !Self.didWarnInstallationPosition {
+ Self.didWarnInstallationPosition = true
+ state.alert = .init {
+ TextState("Move to Applications Folder?")
+ } actions: {
+ ButtonState(action: .moveToApplications) {
+ TextState("Move")
+ }
+ ButtonState(role: .cancel) {
+ TextState("Not Now")
+ }
+ } message: {
+ TextState(
+ "To ensure the best experience, please move the app to the Applications folder. If the app is not inside the Applications folder, please set up the launch agent manually by clicking the button."
+ )
+ }
+ }
+
+ return .none
+
case .setupLaunchAgentIfNeeded:
return .run { send in
#if DEBUG
// do not auto install on debug build
#else
- Task {
- do {
- try await LaunchAgentManager()
- .setupLaunchAgentForTheFirstTimeIfNeeded()
- } catch {
- toast(error.localizedDescription, .error)
- }
+ do {
+ try await LaunchAgentManager()
+ .setupLaunchAgentForTheFirstTimeIfNeeded()
+ } catch {
+ toast(error.localizedDescription, .error)
}
#endif
await send(.reloadStatus)
}
+ case .setupLaunchAgentClicked:
+ if Self.bundleIsInApplicationsFolder {
+ return .run { send in
+ await send(.setupLaunchAgent)
+ }
+ }
+
+ state.alert = .init {
+ TextState("Setup Launch Agent")
+ } actions: {
+ ButtonState(action: .install) {
+ TextState("Setup")
+ }
+
+ ButtonState(action: .moveToApplications) {
+ TextState("Move to Applications Folder")
+ }
+
+ ButtonState(role: .cancel) {
+ TextState("Cancel")
+ }
+ } message: {
+ TextState(
+ "It's recommended to move the app into the Applications folder. But you can still keep it in the current folder and install the launch agent to ~/Library/LaunchAgents."
+ )
+ }
+
+ return .none
+
+ case .removeLaunchAgentClicked:
+ return .run { send in
+ do {
+ try await LaunchAgentManager().removeLaunchAgent()
+ await send(.finishRemoveLaunchAgent)
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ await send(.reloadStatus)
+ }
+
+ case .reloadLaunchAgentClicked:
+ return .run { send in
+ do {
+ try await LaunchAgentManager().reloadLaunchAgent()
+ await send(.finishReloadLaunchAgent)
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ await send(.reloadStatus)
+ }
+
+ case .setupLaunchAgent:
+ return .run { send in
+ do {
+ try await LaunchAgentManager().setupLaunchAgent()
+ await send(.finishSetupLaunchAgent)
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ await send(.reloadStatus)
+ }
+
+ case .finishSetupLaunchAgent:
+ state.alert = .init {
+ TextState("Launch Agent Installed")
+ } actions: {
+ ButtonState {
+ TextState("OK")
+ }
+ } message: {
+ TextState(
+ "The launch agent has been installed. Please restart the app."
+ )
+ }
+ return .none
+
+ case .finishRemoveLaunchAgent:
+ state.alert = .init {
+ TextState("Launch Agent Removed")
+ } actions: {
+ ButtonState {
+ TextState("OK")
+ }
+ } message: {
+ TextState(
+ "The launch agent has been removed."
+ )
+ }
+ return .none
+
+ case .finishReloadLaunchAgent:
+ state.alert = .init {
+ TextState("Launch Agent Reloaded")
+ } actions: {
+ ButtonState {
+ TextState("OK")
+ }
+ } message: {
+ TextState(
+ "The launch agent has been reloaded."
+ )
+ }
+ return .none
+
case .openExtensionManager:
return .run { send in
let service = try getService()
@@ -107,6 +254,38 @@ struct General {
case .failedReloading:
state.isReloading = false
return .none
+
+ case let .alert(.presented(action)):
+ switch action {
+ case .moveToApplications:
+ return .run { send in
+ let appURL = URL(fileURLWithPath: "/Applications")
+ await send(.alert(.presented(.moveTo(appURL))))
+ }
+
+ case let .moveTo(url):
+ return .run { _ in
+ do {
+ try FileManager.default.moveItem(
+ at: Bundle.main.bundleURL,
+ to: url.appendingPathComponent(
+ Bundle.main.bundleURL.lastPathComponent
+ )
+ )
+ await NSApplication.shared.terminate(nil)
+ } catch {
+ toast(error.localizedDescription, .error)
+ }
+ }
+ case .install:
+ return .run { send in
+ await send(.setupLaunchAgent)
+ }
+ }
+
+ case .alert(.dismiss):
+ state.alert = nil
+ return .none
}
}
}
diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift
index ba57a242..6b08c69b 100644
--- a/Core/Sources/HostApp/GeneralView.swift
+++ b/Core/Sources/HostApp/GeneralView.swift
@@ -16,7 +16,7 @@ struct GeneralView: View {
SettingsDivider()
ExtensionServiceView(store: store)
SettingsDivider()
- LaunchAgentView()
+ LaunchAgentView(store: store)
SettingsDivider()
GeneralSettingsView()
}
@@ -30,64 +30,68 @@ struct GeneralView: View {
struct AppInfoView: View {
@State var appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String
@Environment(\.updateChecker) var updateChecker
- let store: StoreOf
+ @Perception.Bindable var store: StoreOf
var body: some View {
- VStack(alignment: .leading) {
- HStack(alignment: .top) {
- Text(
- Bundle.main
- .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String
- ?? "Copilot for Xcode"
- )
- .font(.title)
- Text(appVersion ?? "")
- .font(.footnote)
- .foregroundColor(.secondary)
-
- Spacer()
+ WithPerceptionTracking {
+ VStack(alignment: .leading) {
+ HStack(alignment: .top) {
+ Text(
+ Bundle.main
+ .object(forInfoDictionaryKey: "HOST_APP_NAME") as? String
+ ?? "Copilot for Xcode"
+ )
+ .font(.title)
+ Text(appVersion ?? "")
+ .font(.footnote)
+ .foregroundColor(.secondary)
- Button(action: {
- store.send(.openExtensionManager)
- }) {
- HStack(spacing: 2) {
- Image(systemName: "puzzlepiece.extension.fill")
- Text("Extensions")
+ Spacer()
+
+ Button(action: {
+ store.send(.openExtensionManager)
+ }) {
+ HStack(spacing: 2) {
+ Image(systemName: "puzzlepiece.extension.fill")
+ Text("Extensions")
+ }
}
- }
- Button(action: {
- updateChecker.checkForUpdates()
- }) {
- HStack(spacing: 2) {
- Image(systemName: "arrow.up.right.circle.fill")
- Text("Check for Updates")
+ Button(action: {
+ updateChecker.checkForUpdates()
+ }) {
+ HStack(spacing: 2) {
+ Image(systemName: "arrow.up.right.circle.fill")
+ Text("Check for Updates")
+ }
}
}
- }
- HStack(spacing: 16) {
- Link(
- destination: URL(string: "https://github.com/intitni/CopilotForXcode")!
- ) {
- HStack(spacing: 2) {
- Image(systemName: "link")
- Text("GitHub")
+ HStack(spacing: 16) {
+ Link(
+ destination: URL(string: "https://github.com/intitni/CopilotForXcode")!
+ ) {
+ HStack(spacing: 2) {
+ Image(systemName: "link")
+ Text("GitHub")
+ }
}
- }
- .focusable(false)
- .foregroundColor(.accentColor)
+ .focusable(false)
+ .foregroundColor(.accentColor)
- Link(destination: URL(string: "https://www.buymeacoffee.com/intitni")!) {
- HStack(spacing: 2) {
- Image(systemName: "cup.and.saucer.fill")
- Text("Buy Me A Coffee")
+ Link(destination: URL(string: "https://www.buymeacoffee.com/intitni")!) {
+ HStack(spacing: 2) {
+ Image(systemName: "cup.and.saucer.fill")
+ Text("Buy Me A Coffee")
+ }
}
+ .foregroundColor(.accentColor)
+ .focusable(false)
}
- .foregroundColor(.accentColor)
- .focusable(false)
}
- }.padding()
+ .padding()
+ .alert($store.scope(state: \.alert, action: \.alert))
+ }
}
}
@@ -149,75 +153,34 @@ struct ExtensionServiceView: View {
}
struct LaunchAgentView: View {
+ @Perception.Bindable var store: StoreOf
@Environment(\.toast) var toast
- @State var isDidRemoveLaunchAgentAlertPresented = false
- @State var isDidSetupLaunchAgentAlertPresented = false
- @State var isDidRestartLaunchAgentAlertPresented = false
var body: some View {
- VStack(alignment: .leading) {
- HStack {
- Button(action: {
- Task {
- do {
- try await LaunchAgentManager().setupLaunchAgent()
- isDidSetupLaunchAgentAlertPresented = true
- } catch {
- toast(error.localizedDescription, .error)
- }
+ WithPerceptionTracking {
+ VStack(alignment: .leading) {
+ HStack {
+ Button(action: {
+ store.send(.setupLaunchAgentClicked)
+ }) {
+ Text("Setup Launch Agent")
}
- }) {
- Text("Set Up Launch Agent")
- }
- .alert(isPresented: $isDidSetupLaunchAgentAlertPresented) {
- .init(
- title: Text("Finished Launch Agent Setup"),
- message: Text(
- "Please refresh the Copilot status. (The first refresh may fail)"
- ),
- dismissButton: .default(Text("OK"))
- )
- }
- Button(action: {
- Task {
- do {
- try await LaunchAgentManager().removeLaunchAgent()
- isDidRemoveLaunchAgentAlertPresented = true
- } catch {
- toast(error.localizedDescription, .error)
- }
+ Button(action: {
+ store.send(.removeLaunchAgentClicked)
+ }) {
+ Text("Remove Launch Agent")
}
- }) {
- Text("Remove Launch Agent")
- }
- .alert(isPresented: $isDidRemoveLaunchAgentAlertPresented) {
- .init(
- title: Text("Launch Agent Removed"),
- dismissButton: .default(Text("OK"))
- )
- }
- Button(action: {
- Task {
- do {
- try await LaunchAgentManager().reloadLaunchAgent()
- isDidRestartLaunchAgentAlertPresented = true
- } catch {
- toast(error.localizedDescription, .error)
- }
+ Button(action: {
+ store.send(.reloadLaunchAgentClicked)
+ }) {
+ Text("Reload Launch Agent")
}
- }) {
- Text("Reload Launch Agent")
- }.alert(isPresented: $isDidRestartLaunchAgentAlertPresented) {
- .init(
- title: Text("Launch Agent Reloaded"),
- dismissButton: .default(Text("OK"))
- )
}
}
+ .padding()
}
- .padding()
}
}
diff --git a/Core/Sources/HostApp/HostApp.swift b/Core/Sources/HostApp/HostApp.swift
index 69ec3120..198e48c2 100644
--- a/Core/Sources/HostApp/HostApp.swift
+++ b/Core/Sources/HostApp/HostApp.swift
@@ -4,7 +4,7 @@ import Foundation
import KeyboardShortcuts
#if canImport(LicenseManagement)
-import LicenseManagement
+import ProHostApp
#endif
extension KeyboardShortcuts.Name {
@@ -20,9 +20,8 @@ struct HostApp {
var embeddingModelManagement = EmbeddingModelManagement.State()
}
- enum Action: Equatable {
+ enum Action {
case appear
- case informExtensionServiceAboutLicenseKeyChange
case general(General.Action)
case chatModelManagement(ChatModelManagement.Action)
case embeddingModelManagement(EmbeddingModelManagement.Action)
@@ -50,22 +49,10 @@ struct HostApp {
Reduce { _, action in
switch action {
case .appear:
- return .none
-
- case .informExtensionServiceAboutLicenseKeyChange:
- #if canImport(LicenseManagement)
- return .run { _ in
- let service = try getService()
- do {
- try await service
- .postNotification(name: Notification.Name.licenseKeyChanged.rawValue)
- } catch {
- toast(error.localizedDescription, .error)
- }
- }
- #else
- return .none
+ #if canImport(ProHostApp)
+ ProHostApp.start()
#endif
+ return .none
case .general:
return .none
diff --git a/Core/Sources/HostApp/LaunchAgentManager.swift b/Core/Sources/HostApp/LaunchAgentManager.swift
index ee031cb5..44937bb1 100644
--- a/Core/Sources/HostApp/LaunchAgentManager.swift
+++ b/Core/Sources/HostApp/LaunchAgentManager.swift
@@ -7,11 +7,10 @@ extension LaunchAgentManager {
serviceIdentifier: Bundle.main
.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String +
".CommunicationBridge",
- executablePath: Bundle.main.bundleURL
+ executableURL: Bundle.main.bundleURL
.appendingPathComponent("Contents")
.appendingPathComponent("Applications")
- .appendingPathComponent("CommunicationBridge")
- .path,
+ .appendingPathComponent("CommunicationBridge"),
bundleIdentifier: Bundle.main
.object(forInfoDictionaryKey: "BUNDLE_IDENTIFIER_BASE") as! String
)
diff --git a/Core/Sources/HostApp/ServiceView.swift b/Core/Sources/HostApp/ServiceView.swift
index 07f8af77..2fff4bcf 100644
--- a/Core/Sources/HostApp/ServiceView.swift
+++ b/Core/Sources/HostApp/ServiceView.swift
@@ -33,7 +33,7 @@ struct ServiceView: View {
)).sidebarItem(
tag: 2,
title: "Chat Models",
- subtitle: "Chat, Prompt to Code",
+ subtitle: "Chat, Modification",
image: "globe"
)
@@ -43,7 +43,7 @@ struct ServiceView: View {
)).sidebarItem(
tag: 3,
title: "Embedding Models",
- subtitle: "Chat, Prompt to Code",
+ subtitle: "Chat, Modification",
image: "globe"
)
diff --git a/Core/Sources/HostApp/TabContainer.swift b/Core/Sources/HostApp/TabContainer.swift
index 30ae0187..8616b5af 100644
--- a/Core/Sources/HostApp/TabContainer.swift
+++ b/Core/Sources/HostApp/TabContainer.swift
@@ -2,14 +2,11 @@ import ComposableArchitecture
import Dependencies
import Foundation
import LaunchAgentManager
+import SharedUIComponents
import SwiftUI
import Toast
import UpdateChecker
-#if canImport(ProHostApp)
-import ProHostApp
-#endif
-
@MainActor
let hostAppStore: StoreOf = .init(initialState: .init(), reducer: { HostApp() })
@@ -19,6 +16,10 @@ public struct TabContainer: View {
@State private var tabBarItems = [TabBarItem]()
@State var tag: Int = 0
+ var externalTabContainer: ExternalTabContainer {
+ ExternalTabContainer.tabContainer(for: "TabContainer")
+ }
+
public init() {
toastController = ToastControllerDependencyKey.liveValue
store = hostAppStore
@@ -59,15 +60,16 @@ public struct TabContainer: View {
title: "Custom Command",
image: "command.square"
)
- #if canImport(ProHostApp)
- PlusView(onLicenseKeyChanged: {
- store.send(.informExtensionServiceAboutLicenseKeyChange)
- }).tabBarItem(
- tag: 5,
- title: "Plus",
- image: "plus.diamond"
- )
- #endif
+
+ ForEach(0.. Void,
- dismissSuggestion: @escaping () -> Void
- ) {
- tabToAcceptSuggestion = .init(
- workspacePool: workspacePool,
- acceptSuggestion: acceptSuggestion,
- dismissSuggestion: dismissSuggestion
- )
+ public init() {
+ tabToAcceptSuggestion = .init()
}
public func start() {
diff --git a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
index f1e154e5..d11095a3 100644
--- a/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
+++ b/Core/Sources/KeyBindingManager/TabToAcceptSuggestion.swift
@@ -1,6 +1,8 @@
import ActiveApplicationMonitor
import AppKit
import CGEventOverride
+import CommandHandler
+import Dependencies
import Foundation
import Logger
import Preferences
@@ -14,9 +16,9 @@ final class TabToAcceptSuggestion {
Logger.service.debug("TabToAcceptSuggestion: \(message)")
}
- let workspacePool: WorkspacePool
- let acceptSuggestion: () -> Void
- let dismissSuggestion: () -> Void
+ @Dependency(\.workspacePool) var workspacePool
+ @Dependency(\.commandHandler) var commandHandler
+
private var CGEventObservationTask: Task?
private var isObserving: Bool { CGEventObservationTask != nil }
private let userDefaultsObserver = UserDefaultsObserver(
@@ -43,16 +45,9 @@ final class TabToAcceptSuggestion {
stopObservation()
}
- init(
- workspacePool: WorkspacePool,
- acceptSuggestion: @escaping () -> Void,
- dismissSuggestion: @escaping () -> Void
- ) {
+ init() {
_ = ThreadSafeAccessToXcodeInspector.shared
- self.workspacePool = workspacePool
- self.acceptSuggestion = acceptSuggestion
- self.dismissSuggestion = dismissSuggestion
-
+
hook.add(
.init(
eventsOfInterest: [.keyDown],
@@ -193,7 +188,7 @@ final class TabToAcceptSuggestion {
)
if shouldAcceptSuggestion {
- acceptSuggestion()
+ Task { await commandHandler.acceptSuggestion() }
return .discarded
} else {
return .unchanged
@@ -215,7 +210,7 @@ final class TabToAcceptSuggestion {
filespace.presentingSuggestion != nil
else { return .unchanged }
- dismissSuggestion()
+ Task { await commandHandler.dismissSuggestion() }
return .discarded
default:
return .unchanged
diff --git a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
index e0c8f814..3caf179b 100644
--- a/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
+++ b/Core/Sources/LaunchAgentManager/LaunchAgentManager.swift
@@ -4,7 +4,7 @@ import ServiceManagement
public struct LaunchAgentManager {
let lastLaunchAgentVersionKey = "LastLaunchAgentVersion"
let serviceIdentifier: String
- let executablePath: String
+ let executableURL: URL
let bundleIdentifier: String
var launchAgentDirURL: URL {
@@ -16,15 +16,14 @@ public struct LaunchAgentManager {
launchAgentDirURL.appendingPathComponent("\(serviceIdentifier).plist").path
}
- public init(serviceIdentifier: String, executablePath: String, bundleIdentifier: String) {
+ public init(serviceIdentifier: String, executableURL: URL, bundleIdentifier: String) {
self.serviceIdentifier = serviceIdentifier
- self.executablePath = executablePath
+ self.executableURL = executableURL
self.bundleIdentifier = bundleIdentifier
}
public func setupLaunchAgentForTheFirstTimeIfNeeded() async throws {
if #available(macOS 13, *) {
- await removeObsoleteLaunchAgent()
try await setupLaunchAgent()
} else {
if UserDefaults.standard.integer(forKey: lastLaunchAgentVersionKey) < 40 {
@@ -33,48 +32,18 @@ public struct LaunchAgentManager {
}
guard !FileManager.default.fileExists(atPath: launchAgentPath) else { return }
try await setupLaunchAgent()
- await removeObsoleteLaunchAgent()
}
}
public func setupLaunchAgent() async throws {
if #available(macOS 13, *) {
- let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
- try bridgeLaunchAgent.register()
- } else {
- let content = """
-
-
-
-
- Label
- \(serviceIdentifier)
- Program
- \(executablePath)
- MachServices
-
- \(serviceIdentifier)
-
-
- AssociatedBundleIdentifiers
-
- \(bundleIdentifier)
- \(serviceIdentifier)
-
-
-
- """
- if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) {
- try FileManager.default.createDirectory(
- at: launchAgentDirURL,
- withIntermediateDirectories: false
- )
+ if executableURL.path.hasPrefix("/Applications") {
+ try setupLaunchAgentWithPredefinedPlist()
+ } else {
+ try await setupLaunchAgentWithDynamicPlist()
}
- FileManager.default.createFile(
- atPath: launchAgentPath,
- contents: content.data(using: .utf8)
- )
- try await launchctl("load", launchAgentPath)
+ } else {
+ try await setupLaunchAgentWithDynamicPlist()
}
let buildNumber = (Bundle.main.infoDictionary?["CFBundleVersion"] as? String)
@@ -85,7 +54,11 @@ public struct LaunchAgentManager {
public func removeLaunchAgent() async throws {
if #available(macOS 13, *) {
let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
- try await bridgeLaunchAgent.unregister()
+ try? await bridgeLaunchAgent.unregister()
+ if FileManager.default.fileExists(atPath: launchAgentPath) {
+ try? await launchctl("unload", launchAgentPath)
+ try? FileManager.default.removeItem(atPath: launchAgentPath)
+ }
} else {
try await launchctl("unload", launchAgentPath)
try FileManager.default.removeItem(atPath: launchAgentPath)
@@ -97,23 +70,56 @@ public struct LaunchAgentManager {
try await helper("reload-launch-agent", "--service-identifier", serviceIdentifier)
}
}
+}
- public func removeObsoleteLaunchAgent() async {
- if #available(macOS 13, *) {
- let path = launchAgentPath
- if FileManager.default.fileExists(atPath: path) {
- try? await launchctl("unload", path)
- try? FileManager.default.removeItem(atPath: path)
- }
- } else {
- let path = launchAgentPath.replacingOccurrences(
- of: "ExtensionService",
- with: "XPCService"
+extension LaunchAgentManager {
+ @available(macOS 13, *)
+ func setupLaunchAgentWithPredefinedPlist() throws {
+ let bridgeLaunchAgent = SMAppService.agent(plistName: "bridgeLaunchAgent.plist")
+ try bridgeLaunchAgent.register()
+ }
+
+ func setupLaunchAgentWithDynamicPlist() async throws {
+ if FileManager.default.fileExists(atPath: launchAgentPath) {
+ throw E(errorDescription: "Launch agent already exists.")
+ }
+
+ let content = """
+
+
+
+
+ Label
+ \(serviceIdentifier)
+ Program
+ \(executableURL.path)
+ MachServices
+
+ \(serviceIdentifier)
+
+
+ AssociatedBundleIdentifiers
+
+ \(bundleIdentifier)
+ \(serviceIdentifier)
+
+
+
+ """
+ if !FileManager.default.fileExists(atPath: launchAgentDirURL.path) {
+ try FileManager.default.createDirectory(
+ at: launchAgentDirURL,
+ withIntermediateDirectories: false
)
- if FileManager.default.fileExists(atPath: path) {
- try? FileManager.default.removeItem(atPath: path)
- }
}
+ FileManager.default.createFile(
+ atPath: launchAgentPath,
+ contents: content.data(using: .utf8)
+ )
+ #if DEBUG
+ #else
+ try await launchctl("load", launchAgentPath)
+ #endif
}
}
@@ -170,7 +176,7 @@ private func launchctl(_ args: String...) async throws {
return try await process("/bin/launchctl", args)
}
-struct E: Error, LocalizedError {
+private struct E: Error, LocalizedError {
var errorDescription: String?
}
diff --git a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift
index ca39a2fc..2cc146bd 100644
--- a/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift
+++ b/Core/Sources/PromptToCodeService/OpenAIPromptToCodeService.swift
@@ -5,7 +5,7 @@ import SuggestionBasic
import XcodeInspector
public final class OpenAIPromptToCodeService: PromptToCodeServiceType {
- var service: (any ChatGPTServiceType)?
+ var service: (any LegacyChatGPTServiceType)?
public init() {}
@@ -179,9 +179,10 @@ public final class OpenAIPromptToCodeService: PromptToCodeServiceType {
let memory = AutoManagedChatGPTMemory(
systemPrompt: systemPrompt,
configuration: configuration,
- functionProvider: NoChatGPTFunctionProvider()
+ functionProvider: NoChatGPTFunctionProvider(),
+ maxNumberOfMessages: .max
)
- let chatGPTService = ChatGPTService(
+ let chatGPTService = LegacyChatGPTService(
memory: memory,
configuration: configuration
)
diff --git a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift
index 609af1d4..07acdb87 100644
--- a/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift
+++ b/Core/Sources/PromptToCodeService/PromptToCodeServiceType.swift
@@ -62,6 +62,8 @@ public extension DependencyValues {
import ContextAwarePromptToCodeService
extension ContextAwarePromptToCodeService: PromptToCodeServiceType {
+ public func stopResponding() {}
+
public func modifyCode(
code: String,
requirement: String,
diff --git a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
index 396145f9..ff3fa4ab 100644
--- a/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
+++ b/Core/Sources/Service/GUI/GraphicalUserInterfaceController.swift
@@ -21,10 +21,10 @@ import ChatTabPersistent
@Reducer
struct GUI {
@ObservableState
- struct State: Equatable {
- var suggestionWidgetState = WidgetFeature.State()
+ struct State {
+ var suggestionWidgetState = Widget.State()
- var chatTabGroup: ChatPanelFeature.ChatTabGroup {
+ var chatTabGroup: SuggestionWidget.ChatPanel.ChatTabGroup {
get { suggestionWidgetState.chatPanelState.chatTabGroup }
set { suggestionWidgetState.chatPanelState.chatTabGroup = newValue }
}
@@ -55,7 +55,7 @@ struct GUI {
enum Action {
case start
- case openChatPanel(forceDetach: Bool)
+ case openChatPanel(forceDetach: Bool, activateThisApp: Bool)
case createAndSwitchToChatGPTChatTabIfNeeded
case createAndSwitchToChatTabIfNeededMatching(
check: (any ChatTab) -> Bool,
@@ -64,7 +64,7 @@ struct GUI {
case sendCustomCommandToActiveChat(CustomCommand)
case toggleWidgetsHotkeyPressed
- case suggestionWidget(WidgetFeature.Action)
+ case suggestionWidget(Widget.Action)
static func promptToCodeGroup(_ action: PromptToCodeGroup.Action) -> Self {
.suggestionWidget(.panel(.sharedPanel(.promptToCodeGroup(action))))
@@ -85,7 +85,7 @@ struct GUI {
var body: some ReducerOf {
CombineReducers {
Scope(state: \.suggestionWidgetState, action: \.suggestionWidget) {
- WidgetFeature()
+ Widget()
}
Scope(
@@ -138,7 +138,7 @@ struct GUI {
return .none
#endif
- case let .openChatPanel(forceDetach):
+ case let .openChatPanel(forceDetach, activate):
return .run { send in
await send(
.suggestionWidget(
@@ -147,7 +147,9 @@ struct GUI {
)
await send(.suggestionWidget(.updateKeyWindow(.chatPanel)))
- activateThisApp()
+ if activate {
+ activateThisApp()
+ }
}
case .createAndSwitchToChatGPTChatTabIfNeeded:
@@ -186,7 +188,7 @@ struct GUI {
)
}
}
-
+
case let .sendCustomCommandToActiveChat(command):
@Sendable func stopAndHandleCommand(_ tab: ChatGPTChatTab) async {
if tab.service.isReceivingMessage {
@@ -199,7 +201,7 @@ struct GUI {
let activeTab = chatTabPool.getTab(of: info.id) as? ChatGPTChatTab
{
return .run { send in
- await send(.openChatPanel(forceDetach: false))
+ await send(.openChatPanel(forceDetach: false, activateThisApp: false))
await stopAndHandleCommand(activeTab)
}
}
@@ -211,18 +213,16 @@ struct GUI {
{
state.chatTabGroup.selectedTabId = chatTab.id
return .run { send in
- await send(.openChatPanel(forceDetach: false))
+ await send(.openChatPanel(forceDetach: false, activateThisApp: false))
await stopAndHandleCommand(chatTab)
}
}
return .run { send in
guard let (chatTab, chatTabInfo) = await chatTabPool.createTab(for: nil)
- else {
- return
- }
+ else { return }
await send(.suggestionWidget(.chatPanel(.appendAndSelectTab(chatTabInfo))))
- await send(.openChatPanel(forceDetach: false))
+ await send(.openChatPanel(forceDetach: false, activateThisApp: false))
if let chatTab = chatTab as? ChatGPTChatTab {
await stopAndHandleCommand(chatTab)
}
@@ -298,18 +298,7 @@ public final class GraphicalUserInterfaceController {
dependencies.suggestionWidgetUserDefaultsObservers = .init()
dependencies.chatTabPool = chatTabPool
dependencies.chatTabBuilderCollection = ChatTabFactory.chatTabBuilderCollection
- dependencies.promptToCodeAcceptHandler = { promptToCode in
- Task {
- let handler = PseudoCommandHandler()
- await handler.acceptPromptToCode()
- if !promptToCode.isContinuous {
- NSWorkspace.activatePreviousActiveXcode()
- } else {
- NSWorkspace.activateThisApp()
- }
- }
- }
-
+
#if canImport(ChatTabPersistent) && canImport(ProChatTabs)
dependencies.restoreChatTabInPool = {
await chatTabPool.restore($0)
@@ -349,7 +338,7 @@ public final class GraphicalUserInterfaceController {
suggestionDependency.onOpenChatClicked = { [weak self] in
Task { [weak self] in
await self?.store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish()
- self?.store.send(.openChatPanel(forceDetach: false))
+ self?.store.send(.openChatPanel(forceDetach: false, activateThisApp: true))
}
}
suggestionDependency.onCustomCommandClicked = { command in
diff --git a/Core/Sources/Service/GUI/WidgetDataSource.swift b/Core/Sources/Service/GUI/WidgetDataSource.swift
index 77dd8993..ae1b6371 100644
--- a/Core/Sources/Service/GUI/WidgetDataSource.swift
+++ b/Core/Sources/Service/GUI/WidgetDataSource.swift
@@ -14,7 +14,7 @@ import SuggestionWidget
final class WidgetDataSource {}
extension WidgetDataSource: SuggestionWidgetDataSource {
- func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? {
+ func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion? {
for workspace in Service.shared.workspacePool.workspaces.values {
if let filespace = workspace.filespaces[url],
let suggestion = filespace.presentingSuggestion
@@ -25,39 +25,9 @@ extension WidgetDataSource: SuggestionWidgetDataSource {
startLineIndex: suggestion.position.line,
suggestionCount: filespace.suggestions.count,
currentSuggestionIndex: filespace.suggestionIndex,
- onSelectPreviousSuggestionTapped: {
- Task {
- let handler = PseudoCommandHandler()
- await handler.presentPreviousSuggestion()
- }
- },
- onSelectNextSuggestionTapped: {
- Task {
- let handler = PseudoCommandHandler()
- await handler.presentNextSuggestion()
- }
- },
- onRejectSuggestionTapped: {
- Task {
- let handler = PseudoCommandHandler()
- await handler.rejectSuggestions()
- NSWorkspace.activatePreviousActiveXcode()
- }
- },
- onAcceptSuggestionTapped: {
- Task {
- let handler = PseudoCommandHandler()
- await handler.acceptSuggestion()
- NSWorkspace.activatePreviousActiveXcode()
- }
- },
- onDismissSuggestionTapped: {
- Task {
- let handler = PseudoCommandHandler()
- await handler.dismissSuggestion()
- NSWorkspace.activatePreviousActiveXcode()
- }
- }
+ replacingRange: suggestion.range,
+ replacingLines: suggestion.replacingLines,
+ descriptions: suggestion.descriptions
)
}
}
diff --git a/Core/Sources/Service/GlobalShortcutManager.swift b/Core/Sources/Service/GlobalShortcutManager.swift
index 9620f25a..cc799fc0 100644
--- a/Core/Sources/Service/GlobalShortcutManager.swift
+++ b/Core/Sources/Service/GlobalShortcutManager.swift
@@ -28,7 +28,7 @@ final class GlobalShortcutManager {
!guiController.store.state.suggestionWidgetState.chatPanelState.isPanelDisplayed,
UserDefaults.shared.value(for: \.showHideWidgetShortcutGlobally)
{
- guiController.store.send(.openChatPanel(forceDetach: true))
+ guiController.store.send(.openChatPanel(forceDetach: true, activateThisApp: true))
} else {
guiController.store.send(.toggleWidgetsHotkeyPressed)
}
diff --git a/Core/Sources/Service/RealtimeSuggestionController.swift b/Core/Sources/Service/RealtimeSuggestionController.swift
index 06d6f522..2d00c960 100644
--- a/Core/Sources/Service/RealtimeSuggestionController.swift
+++ b/Core/Sources/Service/RealtimeSuggestionController.swift
@@ -163,10 +163,10 @@ public actor RealtimeSuggestionController {
func cancelInFlightTasks(excluding: Task? = nil) async {
inflightPrefetchTask?.cancel()
-
+ let workspaces = await Service.shared.workspacePool.workspaces
// cancel in-flight tasks
await withTaskGroup(of: Void.self) { group in
- for (_, workspace) in Service.shared.workspacePool.workspaces {
+ for (_, workspace) in workspaces {
group.addTask {
await workspace.cancelInFlightRealtimeSuggestionRequests()
}
diff --git a/Core/Sources/Service/Service.swift b/Core/Sources/Service/Service.swift
index 35ca6a50..ee5be58b 100644
--- a/Core/Sources/Service/Service.swift
+++ b/Core/Sources/Service/Service.swift
@@ -1,6 +1,7 @@
import BuiltinExtension
import CodeiumService
import Combine
+import CommandHandler
import Dependencies
import Foundation
import GitHubCopilotService
@@ -24,13 +25,14 @@ import ProService
/// The running extension service.
public final class Service {
+ @MainActor
public static let shared = Service()
- @WorkspaceActor
- let workspacePool: WorkspacePool
+ @Dependency(\.workspacePool) var workspacePool
@MainActor
- public let guiController = GraphicalUserInterfaceController()
- public let realtimeSuggestionController = RealtimeSuggestionController()
+ public let guiController: GraphicalUserInterfaceController
+ public let commandHandler: CommandHandler
+ public let realtimeSuggestionController: RealtimeSuggestionController
public let scheduledCleaner: ScheduledCleaner
let globalShortcutManager: GlobalShortcutManager
let keyBindingManager: KeyBindingManager
@@ -43,14 +45,29 @@ public final class Service {
@Dependency(\.toast) var toast
var cancellable = Set()
+ @MainActor
private init() {
@Dependency(\.workspacePool) var workspacePool
+ let commandHandler = PseudoCommandHandler()
+ UniversalCommandHandler.shared.commandHandler = commandHandler
+ self.commandHandler = commandHandler
+
+ realtimeSuggestionController = .init()
+ scheduledCleaner = .init()
+ let guiController = GraphicalUserInterfaceController()
+ self.guiController = guiController
+ globalShortcutManager = .init(guiController: guiController)
+ keyBindingManager = .init()
+
+ #if canImport(ProService)
+ proService = ProService()
+ #endif
BuiltinExtensionManager.shared.setupExtensions([
GitHubCopilotExtension(workspacePool: workspacePool),
CodeiumExtension(workspacePool: workspacePool),
])
- scheduledCleaner = .init()
+
workspacePool.registerPlugin {
SuggestionServiceWorkspacePlugin(workspace: $0) { SuggestionService.service() }
}
@@ -63,21 +80,6 @@ public final class Service {
workspacePool.registerPlugin {
BuiltinExtensionWorkspacePlugin(workspace: $0)
}
- self.workspacePool = workspacePool
- globalShortcutManager = .init(guiController: guiController)
- keyBindingManager = .init(
- workspacePool: workspacePool,
- acceptSuggestion: {
- Task { await PseudoCommandHandler().acceptSuggestion() }
- },
- dismissSuggestion: {
- Task { await PseudoCommandHandler().dismissSuggestion() }
- }
- )
-
- #if canImport(ProService)
- proService = ProService()
- #endif
scheduledCleaner.service = self
}
@@ -100,9 +102,10 @@ public final class Service {
.removeDuplicates()
.filter { $0 != .init(fileURLWithPath: "/") }
.compactMap { $0 }
- .sink { [weak self] fileURL in
+ .sink { fileURL in
Task {
- try await self?.workspacePool
+ @Dependency(\.workspacePool) var workspacePool
+ return try await workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: fileURL)
}
}.store(in: &cancellable)
@@ -128,7 +131,7 @@ public extension Service {
) {
do {
#if canImport(ProService)
- try Service.shared.proService.handleXPCServiceRequests(
+ try proService.handleXPCServiceRequests(
endpoint: endpoint,
requestBody: requestBody,
reply: reply
diff --git a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
index 6afc5956..8818df6a 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/PseudoCommandHandler.swift
@@ -1,13 +1,14 @@
import ActiveApplicationMonitor
import AppKit
import CodeiumService
+import CommandHandler
import enum CopilotForXcodeKit.SuggestionServiceError
import Dependencies
import Logger
import PlusFeatureFlag
import Preferences
-import SuggestionInjector
import SuggestionBasic
+import SuggestionInjector
import Toast
import Workspace
import WorkspaceSuggestionService
@@ -21,7 +22,7 @@ import BrowserChatTab
/// It's used to run some commands without really triggering the menu bar item.
///
/// For example, we can use it to generate real-time suggestions without Apple Scripts.
-struct PseudoCommandHandler {
+struct PseudoCommandHandler: CommandHandler {
static var lastTimeCommandFailedToTriggerWithAccessibilityAPI = Date(timeIntervalSince1970: 0)
private var toast: ToastController { ToastControllerDependencyKey.liveValue }
@@ -86,7 +87,7 @@ struct PseudoCommandHandler {
}
let snapshot = FilespaceSuggestionSnapshot(
- linesHash: editor.lines.hashValue,
+ lines: editor.lines,
cursorPosition: editor.cursorPosition
)
@@ -206,20 +207,25 @@ struct PseudoCommandHandler {
}
do {
try await XcodeInspector.shared.safe.latestActiveXcode?
- .triggerCopilotCommand(name: "Accept Prompt to Code")
+ .triggerCopilotCommand(name: "Accept Modification")
} catch {
- let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI
- let now = Date()
- if now.timeIntervalSince(last) > 60 * 60 {
- Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now
- toast.toast(content: """
+ do {
+ try await XcodeInspector.shared.safe.latestActiveXcode?
+ .triggerCopilotCommand(name: "Accept Prompt to Code")
+ } catch {
+ let last = Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI
+ let now = Date()
+ if now.timeIntervalSince(last) > 60 * 60 {
+ Self.lastTimeCommandFailedToTriggerWithAccessibilityAPI = now
+ toast.toast(content: """
The app is using a fallback solution to accept suggestions. \
For better experience, please restart Xcode to re-activate the Copilot \
menu item.
""", type: .warning)
+ }
+
+ throw error
}
-
- throw error
}
} catch {
guard let xcode = ActiveApplicationMonitor.shared.activeXcode
@@ -325,20 +331,22 @@ struct PseudoCommandHandler {
func dismissSuggestion() async {
guard let documentURL = await XcodeInspector.shared.safe.activeDocumentURL else { return }
+ PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL)
guard let (_, filespace) = try? await Service.shared.workspacePool
.fetchOrCreateWorkspaceAndFilespace(fileURL: documentURL) else { return }
-
await filespace.reset()
- PresentInWindowSuggestionPresenter().discardSuggestion(fileURL: documentURL)
}
- func openChat(forceDetach: Bool) {
+ func openChat(forceDetach: Bool, activateThisApp: Bool = true) {
switch UserDefaults.shared.value(for: \.openChatMode) {
case .chatPanel:
- let store = Service.shared.guiController.store
Task { @MainActor in
+ let store = Service.shared.guiController.store
await store.send(.createAndSwitchToChatGPTChatTabIfNeeded).finish()
- store.send(.openChatPanel(forceDetach: forceDetach))
+ store.send(.openChatPanel(
+ forceDetach: forceDetach,
+ activateThisApp: activateThisApp
+ ))
}
case .browser:
let urlString = UserDefaults.shared.value(for: \.openChatInBrowserURL)
@@ -359,8 +367,8 @@ struct PseudoCommandHandler {
if openInApp {
#if canImport(BrowserChatTab)
- let store = Service.shared.guiController.store
Task { @MainActor in
+ let store = Service.shared.guiController.store
await store.send(.createAndSwitchToChatTabIfNeededMatching(
check: {
func match(_ tabURL: URL?) -> Bool {
@@ -375,7 +383,10 @@ struct PseudoCommandHandler {
},
kind: .init(BrowserChatTab.urlChatBuilder(url: url))
)).finish()
- store.send(.openChatPanel(forceDetach: forceDetach))
+ store.send(.openChatPanel(
+ forceDetach: forceDetach,
+ activateThisApp: activateThisApp
+ ))
}
#endif
} else {
@@ -385,18 +396,49 @@ struct PseudoCommandHandler {
}
}
case .codeiumChat:
- let store = Service.shared.guiController.store
Task { @MainActor in
+ let store = Service.shared.guiController.store
await store.send(
.createAndSwitchToChatTabIfNeededMatching(
check: { $0 is CodeiumChatTab },
kind: .init(CodeiumChatTab.defaultChatBuilder())
)
).finish()
- store.send(.openChatPanel(forceDetach: forceDetach))
+ store.send(.openChatPanel(
+ forceDetach: forceDetach,
+ activateThisApp: activateThisApp
+ ))
}
}
}
+
+ @MainActor
+ func sendChatMessage(_ message: String) async {
+ let store = Service.shared.guiController.store
+ await store.send(.sendCustomCommandToActiveChat(CustomCommand(
+ commandId: "",
+ name: "",
+ feature: .chatWithSelection(
+ extraSystemPrompt: nil,
+ prompt: message,
+ useExtraSystemPrompt: nil
+ )
+ ))).finish()
+ }
+
+ @WorkspaceActor
+ func presentSuggestions(_ suggestions: [SuggestionBasic.CodeSuggestion]) async {
+ guard let filespace = await getFilespace() else { return }
+ filespace.setSuggestions(suggestions)
+ PresentInWindowSuggestionPresenter().presentSuggestion(fileURL: filespace.fileURL)
+ }
+
+ func toast(_ message: String, as type: ToastType) {
+ Task { @MainActor in
+ let store = Service.shared.guiController.store
+ store.send(.suggestionWidget(.toastPanel(.toast(.toast(message, type, nil)))))
+ }
+ }
}
extension PseudoCommandHandler {
@@ -422,7 +464,7 @@ extension PseudoCommandHandler {
// recover selection range
- if let selection = result.newSelection {
+ if let selection = result.newSelections.first {
var range = SourceEditor.convertCursorRangeToRange(selection, in: result.content)
if let value = AXValueCreate(.cfRange, &range) {
AXUIElementSetAttributeValue(
diff --git a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift
index eb58241b..939b9652 100644
--- a/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift
+++ b/Core/Sources/Service/SuggestionCommandHandler/WindowBaseCommandHandler.swift
@@ -1,12 +1,14 @@
import AppKit
import ChatService
+import ComposableArchitecture
import Foundation
import GitHubCopilotService
import LanguageServerProtocol
import Logger
import OpenAIService
-import SuggestionInjector
+import PromptToCodeBasic
import SuggestionBasic
+import SuggestionInjector
import SuggestionWidget
import UserNotifications
import Workspace
@@ -178,58 +180,69 @@ struct WindowBaseCommandHandler: SuggestionCommandHandler {
var cursorPosition = editor.cursorPosition
var extraInfo = SuggestionInjector.ExtraInfo()
- let store = Service.shared.guiController.store
+ let store = await Service.shared.guiController.store
- if let promptToCode = store.state.promptToCodeGroup.activePromptToCode {
- if promptToCode.isAttachedToSelectionRange, promptToCode.documentURL != fileURL {
+ if let promptToCode = await MainActor
+ .run(body: { store.state.promptToCodeGroup.activePromptToCode })
+ {
+ if promptToCode.promptToCodeState.isAttachedToTarget,
+ promptToCode.promptToCodeState.source.documentURL != fileURL
+ {
return nil
}
- let range = {
- if promptToCode.isAttachedToSelectionRange,
- let range = promptToCode.selectionRange
- {
- return range
+ let suggestions = promptToCode.promptToCodeState.snippets
+ .map { snippet in
+ let range = {
+ if promptToCode.promptToCodeState.isAttachedToTarget {
+ return snippet.attachedRange
+ }
+ return editor.selections.first.map {
+ CursorRange(start: $0.start, end: $0.end)
+ } ?? CursorRange(
+ start: editor.cursorPosition,
+ end: editor.cursorPosition
+ )
+ }()
+ return CodeSuggestion(
+ id: snippet.id.uuidString,
+ text: snippet.modifiedCode,
+ position: range.start,
+ range: range
+ )
}
- return editor.selections.first.map {
- CursorRange(start: $0.start, end: $0.end)
- } ?? CursorRange(
- start: editor.cursorPosition,
- end: editor.cursorPosition
- )
- }()
- let suggestion = CodeSuggestion(
- id: UUID().uuidString,
- text: promptToCode.code,
- position: range.start,
- range: range
- )
-
- injector.acceptSuggestion(
+ injector.acceptSuggestions(
intoContentWithoutSuggestion: &lines,
cursorPosition: &cursorPosition,
- completion: suggestion,
+ completions: suggestions,
extraInfo: &extraInfo
)
- _ = await Task { @MainActor [cursorPosition] in
- store.send(
- .promptToCodeGroup(.updatePromptToCodeRange(
- id: promptToCode.id,
- range: .init(start: range.start, end: cursorPosition)
- ))
- )
+ for (id, range) in extraInfo.modificationRanges {
+ _ = await MainActor.run {
+ store.send(
+ .promptToCodeGroup(.updatePromptToCodeRange(
+ id: promptToCode.id,
+ snippetId: .init(uuidString: id) ?? .init(),
+ range: range
+ ))
+ )
+ }
+ }
+
+ _ = await MainActor.run {
store.send(
.promptToCodeGroup(.discardAcceptedPromptToCodeIfNotContinuous(
id: promptToCode.id
))
)
- }.result
+ }
return .init(
content: String(lines.joined(separator: "")),
- newSelection: .init(start: range.start, end: cursorPosition),
+ newSelections: extraInfo.modificationRanges.values
+ .sorted(by: { $0.start.line <= $1.start.line }),
modifications: extraInfo.modifications
)
}
@@ -352,11 +365,49 @@ extension WindowBaseCommandHandler {
let codeLanguage = languageIdentifierFromFileURL(fileURL)
- let (code, selection) = {
- guard var selection = editor.selections.last,
- selection.start != selection.end
- else { return ("", .cursor(editor.cursorPosition)) }
+ let selections: [CursorRange] = {
+ var all = [CursorRange]()
+
+ // join the ranges if they overlaps in line
+ for selection in editor.selections {
+ let range = CursorRange(start: selection.start, end: selection.end)
+
+ func intersect(_ lhs: CursorRange, _ rhs: CursorRange) -> Bool {
+ lhs.start.line <= rhs.end.line && lhs.end.line >= rhs.start.line
+ }
+
+ if let last = all.last, intersect(last, range) {
+ all[all.count - 1] = CursorRange(
+ start: .init(
+ line: min(last.start.line, range.start.line),
+ character: min(last.start.character, range.start.character)
+ ),
+ end: .init(
+ line: max(last.end.line, range.end.line),
+ character: max(last.end.character, range.end.character)
+ )
+ )
+ } else {
+ all.append(range)
+ }
+ }
+
+ return all
+ }()
+
+ let snippets = selections.map { selection in
+ guard selection.start != selection.end else {
+ return PromptToCodeSnippet(
+ startLineIndex: selection.start.line,
+ originalCode: "",
+ modifiedCode: "",
+ description: "",
+ error: "",
+ attachedRange: selection
+ )
+ }
+ var selection = selection
let isMultipleLine = selection.start.line != selection.end.line
let isSpaceOnlyBeforeStartPositionOnTheSameLine = {
guard selection.start.line >= 0, selection.start.line < editor.lines.count else {
@@ -379,16 +430,21 @@ extension WindowBaseCommandHandler {
// indentation.
selection.start = .init(line: selection.start.line, character: 0)
}
- return (
- editor.selectedCode(in: selection),
- .init(
- start: .init(line: selection.start.line, character: selection.start.character),
- end: .init(line: selection.end.line, character: selection.end.character)
- )
+ let selectedCode = editor.selectedCode(in: .init(
+ start: selection.start,
+ end: selection.end
+ ))
+ return PromptToCodeSnippet(
+ startLineIndex: selection.start.line,
+ originalCode: selectedCode,
+ modifiedCode: selectedCode,
+ description: "",
+ error: "",
+ attachedRange: .init(start: selection.start, end: selection.end)
)
- }() as (String, CursorRange)
+ }
- let store = Service.shared.guiController.store
+ let store = await Service.shared.guiController.store
let customCommandTemplateProcessor = CustomCommandTemplateProcessor()
@@ -404,25 +460,27 @@ extension WindowBaseCommandHandler {
nil
}
- _ = await Task { @MainActor in
- // if there is already a prompt to code presenting, we should not present another one
+ _ = await MainActor.run {
store.send(.promptToCodeGroup(.activateOrCreatePromptToCode(.init(
- code: code,
- selectionRange: selection,
- language: codeLanguage,
- identSize: filespace.codeMetadata.indentSize ?? 4,
+ promptToCodeState: Shared(.init(
+ source: .init(
+ language: codeLanguage,
+ documentURL: fileURL,
+ projectRootURL: workspace.projectRootURL,
+ content: editor.content,
+ lines: editor.lines
+ ),
+ snippets: IdentifiedArray(uniqueElements: snippets),
+ instruction: newPrompt ?? "",
+ extraSystemPrompt: newExtraSystemPrompt ?? "",
+ isAttachedToTarget: true
+ )),
+ indentSize: filespace.codeMetadata.indentSize ?? 4,
usesTabsForIndentation: filespace.codeMetadata.usesTabsForIndentation ?? false,
- documentURL: fileURL,
- projectRootURL: workspace.projectRootURL,
- allCode: editor.content,
- allLines: editor.lines,
- isContinuous: isContinuous,
commandName: name,
- defaultPrompt: newPrompt ?? "",
- extraSystemPrompt: newExtraSystemPrompt,
- generateDescriptionRequirement: generateDescription
+ isContinuous: isContinuous
))))
- }.result
+ }
}
func executeSingleRoundDialog(
diff --git a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
index ce2eb039..7069422b 100644
--- a/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
+++ b/Core/Sources/Service/SuggestionPresenter/PresentInWindowSuggestionPresenter.swift
@@ -42,17 +42,10 @@ struct PresentInWindowSuggestionPresenter {
}
}
- func closeChatRoom(fileURL: URL) {
- Task { @MainActor in
- let controller = Service.shared.guiController.widgetController
- controller.closeChatRoom()
- }
- }
-
func presentChatRoom(fileURL: URL) {
Task { @MainActor in
- let controller = Service.shared.guiController.widgetController
- controller.presentChatRoom()
+ let controller = Service.shared.guiController
+ controller.store.send(.openChatPanel(forceDetach: false, activateThisApp: true))
}
}
}
diff --git a/Core/Sources/SuggestionService/SuggestionService.swift b/Core/Sources/SuggestionService/SuggestionService.swift
index 2d6cc2da..335f0c83 100644
--- a/Core/Sources/SuggestionService/SuggestionService.swift
+++ b/Core/Sources/SuggestionService/SuggestionService.swift
@@ -1,7 +1,7 @@
import BuiltinExtension
import CodeiumService
-import struct CopilotForXcodeKit.WorkspaceInfo
import enum CopilotForXcodeKit.SuggestionServiceError
+import struct CopilotForXcodeKit.WorkspaceInfo
import Foundation
import GitHubCopilotService
import Preferences
@@ -17,21 +17,25 @@ import ProExtension
public protocol SuggestionServiceType: SuggestionServiceProvider {}
public actor SuggestionService: SuggestionServiceType {
+ public typealias Middleware = SuggestionServiceMiddleware
+ public typealias EventHandler = SuggestionServiceEventHandler
public var configuration: SuggestionProvider.SuggestionServiceConfiguration {
get async { await suggestionProvider.configuration }
}
- let middlewares: [SuggestionServiceMiddleware]
+ let middlewares: [Middleware]
+ let eventHandlers: [EventHandler]
let suggestionProvider: SuggestionServiceProvider
public init(
provider: any SuggestionServiceProvider,
- middlewares: [SuggestionServiceMiddleware] = SuggestionServiceMiddlewareContainer
- .middlewares
+ middlewares: [Middleware] = SuggestionServiceMiddlewareContainer.middlewares,
+ eventHandlers: [EventHandler] = SuggestionServiceEventHandlerContainer.handlers
) {
suggestionProvider = provider
self.middlewares = middlewares
+ self.eventHandlers = eventHandlers
}
public static func service(
@@ -67,7 +71,7 @@ public extension SuggestionService {
do {
var getSuggestion = suggestionProvider.getSuggestions(_:workspaceInfo:)
let configuration = await configuration
-
+
for middleware in middlewares.reversed() {
getSuggestion = { [getSuggestion] request, workspaceInfo in
try await middleware.getSuggestion(
@@ -79,7 +83,7 @@ public extension SuggestionService {
)
}
}
-
+
return try await getSuggestion(request, workspaceInfo)
} catch let error as SuggestionServiceError {
throw error
@@ -92,6 +96,7 @@ public extension SuggestionService {
_ suggestion: SuggestionBasic.CodeSuggestion,
workspaceInfo: CopilotForXcodeKit.WorkspaceInfo
) async {
+ eventHandlers.forEach { $0.didAccept(suggestion, workspaceInfo: workspaceInfo) }
await suggestionProvider.notifyAccepted(suggestion, workspaceInfo: workspaceInfo)
}
@@ -99,6 +104,7 @@ public extension SuggestionService {
_ suggestions: [SuggestionBasic.CodeSuggestion],
workspaceInfo: CopilotForXcodeKit.WorkspaceInfo
) async {
+ eventHandlers.forEach { $0.didReject(suggestions, workspaceInfo: workspaceInfo) }
await suggestionProvider.notifyRejected(suggestions, workspaceInfo: workspaceInfo)
}
diff --git a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
index 35590f2b..462fc1f4 100644
--- a/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
+++ b/Core/Sources/SuggestionWidget/ChatPanelWindow.swift
@@ -41,7 +41,7 @@ final class ChatPanelWindow: WidgetWindow {
}
init(
- store: StoreOf,
+ store: StoreOf,
chatTabPool: ChatTabPool,
minimizeWindow: @escaping () -> Void
) {
diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift
index 06095086..ee655d0b 100644
--- a/Core/Sources/SuggestionWidget/ChatWindowView.swift
+++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift
@@ -8,7 +8,7 @@ import SwiftUI
private let r: Double = 8
struct ChatWindowView: View {
- let store: StoreOf
+ let store: StoreOf
let toggleVisibility: (Bool) -> Void
var body: some View {
@@ -38,7 +38,7 @@ struct ChatWindowView: View {
}
struct ChatTitleBar: View {
- let store: StoreOf
+ let store: StoreOf
@State var isHovering = false
var body: some View {
@@ -136,7 +136,7 @@ private extension View {
}
struct ChatTabBar: View {
- let store: StoreOf
+ let store: StoreOf
struct TabBarState: Equatable {
var tabInfo: IdentifiedArray
@@ -160,7 +160,7 @@ struct ChatTabBar: View {
}
struct Tabs: View {
- let store: StoreOf
+ let store: StoreOf
@State var draggingTabId: String?
@Environment(\.chatTabPool) var chatTabPool
@@ -226,7 +226,7 @@ struct ChatTabBar: View {
}
struct CreateButton: View {
- let store: StoreOf
+ let store: StoreOf
var body: some View {
WithPerceptionTracking {
@@ -278,7 +278,7 @@ struct ChatTabBar: View {
}
struct ChatTabBarDropDelegate: DropDelegate {
- let store: StoreOf
+ let store: StoreOf
let tabs: IdentifiedArray
let itemId: String
@Binding var draggingTabId: String?
@@ -302,7 +302,7 @@ struct ChatTabBarDropDelegate: DropDelegate {
}
struct ChatTabBarButton: View {
- let store: StoreOf
+ let store: StoreOf
let info: ChatTabInfo
let content: () -> Content
let icon: () -> Icon
@@ -347,7 +347,7 @@ struct ChatTabBarButton: View {
}
struct ChatTabContainer: View {
- let store: StoreOf
+ let store: StoreOf
@Environment(\.chatTabPool) var chatTabPool
var body: some View {
@@ -406,8 +406,8 @@ struct ChatWindowView_Previews: PreviewProvider {
"7": EmptyChatTab(id: "7"),
])
- static func createStore() -> StoreOf {
- StoreOf(
+ static func createStore() -> StoreOf {
+ StoreOf(
initialState: .init(
chatTabGroup: .init(
tabInfo: [
@@ -422,7 +422,7 @@ struct ChatWindowView_Previews: PreviewProvider {
),
isPanelDisplayed: true
),
- reducer: { ChatPanelFeature() }
+ reducer: { ChatPanel() }
)
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift
similarity index 99%
rename from Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift
index 4707c56f..ebc190d3 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/ChatPanel.swift
@@ -23,7 +23,7 @@ public struct ChatTabKind: Equatable {
}
@Reducer
-public struct ChatPanelFeature {
+public struct ChatPanel {
public struct ChatTabGroup: Equatable {
public var tabInfo: IdentifiedArray
public var tabCollection: [ChatTabBuilderCollection]
@@ -166,7 +166,6 @@ public struct ChatPanelFeature {
.chatPanelWindow
.centerInActiveSpaceIfNeeded()
}
- activateExtensionService()
await send(.focusActiveChatTab)
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift
similarity index 98%
rename from Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift
index 51b7d918..0a9e11c2 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidgetFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/CircularWidget.swift
@@ -5,7 +5,7 @@ import SuggestionBasic
import SwiftUI
@Reducer
-public struct CircularWidgetFeature {
+public struct CircularWidget {
public struct IsProcessingCounter: Equatable {
var expirationDate: TimeInterval
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift
deleted file mode 100644
index 9ba5cad3..00000000
--- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCode.swift
+++ /dev/null
@@ -1,276 +0,0 @@
-import AppKit
-import ComposableArchitecture
-import CustomAsyncAlgorithms
-import Dependencies
-import Foundation
-import PromptToCodeService
-import SuggestionBasic
-
-public struct PromptToCodeAcceptHandlerDependencyKey: DependencyKey {
- public static let liveValue: (PromptToCode.State) -> Void = { _ in
- assertionFailure("Please provide a handler")
- }
-
- public static let previewValue: (PromptToCode.State) -> Void = { _ in
- print("Accept Prompt to Code")
- }
-}
-
-public extension DependencyValues {
- var promptToCodeAcceptHandler: (PromptToCode.State) -> Void {
- get { self[PromptToCodeAcceptHandlerDependencyKey.self] }
- set { self[PromptToCodeAcceptHandlerDependencyKey.self] = newValue }
- }
-}
-
-@Reducer
-public struct PromptToCode {
- @ObservableState
- public struct State: Equatable, Identifiable {
- public indirect enum HistoryNode: Equatable {
- case empty
- case node(code: String, description: String, previous: HistoryNode)
-
- mutating func enqueue(code: String, description: String) {
- let current = self
- self = .node(code: code, description: description, previous: current)
- }
-
- mutating func pop() -> (code: String, description: String)? {
- switch self {
- case .empty:
- return nil
- case let .node(code, description, previous):
- self = previous
- return (code, description)
- }
- }
- }
-
- public enum FocusField: Equatable {
- case textField
- }
-
- public var id: URL { documentURL }
- public var history: HistoryNode
- public var code: String
- public var isResponding: Bool
- public var description: String
- public var error: String?
- public var selectionRange: CursorRange?
- public var language: CodeLanguage
- public var indentSize: Int
- public var usesTabsForIndentation: Bool
- public var projectRootURL: URL
- public var documentURL: URL
- public var allCode: String
- public var allLines: [String]
- public var extraSystemPrompt: String?
- public var generateDescriptionRequirement: Bool?
- public var commandName: String?
- public var prompt: String
- public var isContinuous: Bool
- public var isAttachedToSelectionRange: Bool
- public var focusedField: FocusField? = .textField
-
- public var filename: String { documentURL.lastPathComponent }
- public var canRevert: Bool { history != .empty }
-
- public init(
- code: String,
- prompt: String,
- language: CodeLanguage,
- indentSize: Int,
- usesTabsForIndentation: Bool,
- projectRootURL: URL,
- documentURL: URL,
- allCode: String,
- allLines: [String],
- commandName: String? = nil,
- description: String = "",
- isResponding: Bool = false,
- isAttachedToSelectionRange: Bool = true,
- error: String? = nil,
- history: HistoryNode = .empty,
- isContinuous: Bool = false,
- selectionRange: CursorRange? = nil,
- extraSystemPrompt: String? = nil,
- generateDescriptionRequirement: Bool? = nil
- ) {
- self.history = history
- self.code = code
- self.prompt = prompt
- self.isResponding = isResponding
- self.description = description
- self.error = error
- self.isContinuous = isContinuous
- self.selectionRange = selectionRange
- self.language = language
- self.indentSize = indentSize
- self.usesTabsForIndentation = usesTabsForIndentation
- self.projectRootURL = projectRootURL
- self.documentURL = documentURL
- self.allCode = allCode
- self.allLines = allLines
- self.extraSystemPrompt = extraSystemPrompt
- self.generateDescriptionRequirement = generateDescriptionRequirement
- self.isAttachedToSelectionRange = isAttachedToSelectionRange
- self.commandName = commandName
-
- if selectionRange?.isEmpty ?? true {
- self.isAttachedToSelectionRange = false
- }
- }
- }
-
- public enum Action: Equatable, BindableAction {
- case binding(BindingAction)
- case focusOnTextField
- case selectionRangeToggleTapped
- case modifyCodeButtonTapped
- case revertButtonTapped
- case stopRespondingButtonTapped
- case modifyCodeFinished
- case modifyCodeChunkReceived(code: String, description: String)
- case modifyCodeFailed(error: String)
- case modifyCodeCancelled
- case cancelButtonTapped
- case acceptButtonTapped
- case copyCodeButtonTapped
- case appendNewLineToPromptButtonTapped
- }
-
- @Dependency(\.promptToCodeService) var promptToCodeService
- @Dependency(\.promptToCodeAcceptHandler) var promptToCodeAcceptHandler
-
- enum CancellationKey: Hashable {
- case modifyCode(State.ID)
- }
-
- public var body: some ReducerOf {
- BindingReducer()
-
- Reduce { state, action in
- switch action {
- case .binding:
- return .none
-
- case .focusOnTextField:
- state.focusedField = .textField
- return .none
-
- case .selectionRangeToggleTapped:
- state.isAttachedToSelectionRange.toggle()
- return .none
-
- case .modifyCodeButtonTapped:
- guard !state.isResponding else { return .none }
- let copiedState = state
- state.history.enqueue(code: state.code, description: state.description)
- state.isResponding = true
- state.code = ""
- state.description = ""
- state.error = nil
-
- return .run { send in
- do {
- let stream = try await promptToCodeService.modifyCode(
- code: copiedState.code,
- requirement: copiedState.prompt,
- source: .init(
- language: copiedState.language,
- documentURL: copiedState.documentURL,
- projectRootURL: copiedState.projectRootURL,
- content: copiedState.allCode,
- lines: copiedState.allLines,
- range: copiedState.selectionRange ?? .outOfScope
- ),
- isDetached: !copiedState.isAttachedToSelectionRange,
- extraSystemPrompt: copiedState.extraSystemPrompt,
- generateDescriptionRequirement: copiedState
- .generateDescriptionRequirement
- ).timedDebounce(for: 0.2)
-
- for try await fragment in stream {
- try Task.checkCancellation()
- await send(.modifyCodeChunkReceived(
- code: fragment.code,
- description: fragment.description
- ))
- }
- try Task.checkCancellation()
- await send(.modifyCodeFinished)
- } catch is CancellationError {
- try Task.checkCancellation()
- await send(.modifyCodeCancelled)
- } catch {
- try Task.checkCancellation()
- if (error as NSError).code == NSURLErrorCancelled {
- await send(.modifyCodeCancelled)
- return
- }
-
- await send(.modifyCodeFailed(error: error.localizedDescription))
- }
- }.cancellable(id: CancellationKey.modifyCode(state.id), cancelInFlight: true)
-
- case .revertButtonTapped:
- guard let (code, description) = state.history.pop() else { return .none }
- state.code = code
- state.description = description
- return .none
-
- case .stopRespondingButtonTapped:
- state.isResponding = false
- promptToCodeService.stopResponding()
- return .cancel(id: CancellationKey.modifyCode(state.id))
-
- case let .modifyCodeChunkReceived(code, description):
- state.code = code
- state.description = description
- return .none
-
- case .modifyCodeFinished:
- state.prompt = ""
- state.isResponding = false
- if state.code.isEmpty, state.description.isEmpty {
- // if both code and description are empty, we treat it as failed
- return .run { send in
- await send(.revertButtonTapped)
- }
- }
-
- return .none
-
- case let .modifyCodeFailed(error):
- state.error = error
- state.isResponding = false
- return .run { send in
- await send(.revertButtonTapped)
- }
-
- case .modifyCodeCancelled:
- state.isResponding = false
- return .none
-
- case .cancelButtonTapped:
- promptToCodeService.stopResponding()
- return .none
-
- case .acceptButtonTapped:
- promptToCodeAcceptHandler(state)
- return .none
-
- case .copyCodeButtonTapped:
- NSPasteboard.general.clearContents()
- NSPasteboard.general.setString(state.code, forType: .string)
- return .none
-
- case .appendNewLineToPromptButtonTapped:
- state.prompt += "\n"
- return .none
- }
- }
- }
-}
-
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift
index b9617798..114389e3 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodeGroup.swift
@@ -7,13 +7,15 @@ import XcodeInspector
@Reducer
public struct PromptToCodeGroup {
@ObservableState
- public struct State: Equatable {
- public var promptToCodes: IdentifiedArrayOf = []
- public var activeDocumentURL: PromptToCode.State.ID? = XcodeInspector.shared
+ public struct State {
+ public var promptToCodes: IdentifiedArrayOf = []
+ public var activeDocumentURL: PromptToCodePanel.State.ID? = XcodeInspector.shared
.realtimeActiveDocumentURL
- public var activePromptToCode: PromptToCode.State? {
+ public var activePromptToCode: PromptToCodePanel.State? {
get {
- if let detached = promptToCodes.first(where: { !$0.isAttachedToSelectionRange }) {
+ if let detached = promptToCodes
+ .first(where: { !$0.promptToCodeState.isAttachedToTarget })
+ {
return detached
}
guard let id = activeDocumentURL else { return nil }
@@ -27,65 +29,20 @@ public struct PromptToCodeGroup {
}
}
- public struct PromptToCodeInitialState: Equatable {
- public var code: String
- public var selectionRange: CursorRange?
- public var language: CodeLanguage
- public var identSize: Int
- public var usesTabsForIndentation: Bool
- public var documentURL: URL
- public var projectRootURL: URL
- public var allCode: String
- public var allLines: [String]
- public var isContinuous: Bool
- public var commandName: String?
- public var defaultPrompt: String
- public var extraSystemPrompt: String?
- public var generateDescriptionRequirement: Bool?
-
- public init(
- code: String,
- selectionRange: CursorRange?,
- language: CodeLanguage,
- identSize: Int,
- usesTabsForIndentation: Bool,
- documentURL: URL,
- projectRootURL: URL,
- allCode: String,
- allLines: [String],
- isContinuous: Bool,
- commandName: String?,
- defaultPrompt: String,
- extraSystemPrompt: String?,
- generateDescriptionRequirement: Bool?
- ) {
- self.code = code
- self.selectionRange = selectionRange
- self.language = language
- self.identSize = identSize
- self.usesTabsForIndentation = usesTabsForIndentation
- self.documentURL = documentURL
- self.projectRootURL = projectRootURL
- self.allCode = allCode
- self.allLines = allLines
- self.isContinuous = isContinuous
- self.commandName = commandName
- self.defaultPrompt = defaultPrompt
- self.extraSystemPrompt = extraSystemPrompt
- self.generateDescriptionRequirement = generateDescriptionRequirement
- }
- }
-
- public enum Action: Equatable {
+ public enum Action {
/// Activate the prompt to code if it exists or create it if it doesn't
- case activateOrCreatePromptToCode(PromptToCodeInitialState)
- case createPromptToCode(PromptToCodeInitialState)
- case updatePromptToCodeRange(id: PromptToCode.State.ID, range: CursorRange)
- case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCode.State.ID)
+ case activateOrCreatePromptToCode(PromptToCodePanel.State)
+ case createPromptToCode(PromptToCodePanel.State)
+ case updatePromptToCodeRange(
+ id: PromptToCodePanel.State.ID,
+ snippetId: UUID,
+ range: CursorRange
+ )
+ case discardAcceptedPromptToCodeIfNotContinuous(id: PromptToCodePanel.State.ID)
case updateActivePromptToCode(documentURL: URL)
case discardExpiredPromptToCode(documentURLs: [URL])
- case promptToCode(PromptToCode.State.ID, PromptToCode.Action)
- case activePromptToCode(PromptToCode.Action)
+ case promptToCode(PromptToCodePanel.State.ID, PromptToCodePanel.Action)
+ case activePromptToCode(PromptToCodePanel.Action)
}
@Dependency(\.promptToCodeServiceFactory) var promptToCodeServiceFactory
@@ -103,42 +60,27 @@ public struct PromptToCodeGroup {
return .run { send in
await send(.createPromptToCode(s))
}
- case let .createPromptToCode(s):
- let newPromptToCode = PromptToCode.State(
- code: s.code,
- prompt: s.defaultPrompt,
- language: s.language,
- indentSize: s.identSize,
- usesTabsForIndentation: s.usesTabsForIndentation,
- projectRootURL: s.projectRootURL,
- documentURL: s.documentURL,
- allCode: s.allCode,
- allLines: s.allLines,
- commandName: s.commandName,
- isContinuous: s.isContinuous,
- selectionRange: s.selectionRange,
- extraSystemPrompt: s.extraSystemPrompt,
- generateDescriptionRequirement: s.generateDescriptionRequirement
- )
+ case let .createPromptToCode(newPromptToCode):
// insert at 0 so it has high priority then the other detached prompt to codes
state.promptToCodes.insert(newPromptToCode, at: 0)
return .run { send in
- if !newPromptToCode.prompt.isEmpty {
+ if !newPromptToCode.promptToCodeState.instruction.isEmpty {
await send(.promptToCode(newPromptToCode.id, .modifyCodeButtonTapped))
}
}.cancellable(
- id: PromptToCode.CancellationKey.modifyCode(newPromptToCode.id),
+ id: PromptToCodePanel.CancellationKey.modifyCode(newPromptToCode.id),
cancelInFlight: true
)
- case let .updatePromptToCodeRange(id, range):
- if let p = state.promptToCodes[id: id], p.isAttachedToSelectionRange {
- state.promptToCodes[id: id]?.selectionRange = range
+ case let .updatePromptToCodeRange(id, snippetId, range):
+ if let p = state.promptToCodes[id: id], p.promptToCodeState.isAttachedToTarget {
+ state.promptToCodes[id: id]?.promptToCodeState.snippets[id: snippetId]?
+ .attachedRange = range
}
return .none
case let .discardAcceptedPromptToCodeIfNotContinuous(id):
- state.promptToCodes.removeAll { $0.id == id && !$0.isContinuous }
+ state.promptToCodes.removeAll { $0.id == id && $0.hasEnded }
return .none
case let .updateActivePromptToCode(documentURL):
@@ -153,20 +95,20 @@ public struct PromptToCodeGroup {
case .promptToCode:
return .none
-
+
case .activePromptToCode:
return .none
}
}
.ifLet(\.activePromptToCode, action: \.activePromptToCode) {
- PromptToCode()
+ PromptToCodePanel()
.dependency(\.promptToCodeService, promptToCodeServiceFactory())
}
.forEach(\.promptToCodes, action: /Action.promptToCode, element: {
- PromptToCode()
+ PromptToCodePanel()
.dependency(\.promptToCodeService, promptToCodeServiceFactory())
})
-
+
Reduce { state, action in
switch action {
case let .promptToCode(id, .cancelButtonTapped):
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift
new file mode 100644
index 00000000..6f2d67eb
--- /dev/null
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift
@@ -0,0 +1,290 @@
+import AppKit
+import ComposableArchitecture
+import CustomAsyncAlgorithms
+import Dependencies
+import Foundation
+import Preferences
+import PromptToCodeBasic
+import PromptToCodeCustomization
+import PromptToCodeService
+import SuggestionBasic
+
+@Reducer
+public struct PromptToCodePanel {
+ @ObservableState
+ public struct State: Identifiable {
+ public enum FocusField: Equatable {
+ case textField
+ }
+
+ @Shared public var promptToCodeState: PromptToCodeState
+
+ public var id: URL { promptToCodeState.source.documentURL }
+
+ public var indentSize: Int
+ public var usesTabsForIndentation: Bool
+ public var commandName: String?
+ public var isContinuous: Bool
+ public var focusedField: FocusField? = .textField
+
+ public var filename: String {
+ promptToCodeState.source.documentURL.lastPathComponent
+ }
+
+ public var canRevert: Bool { !promptToCodeState.history.isEmpty }
+
+ public var generateDescriptionRequirement: Bool
+
+ public var hasEnded = false
+
+ public var snippetPanels: IdentifiedArrayOf {
+ get {
+ IdentifiedArrayOf(
+ uniqueElements: promptToCodeState.snippets.reversed().map {
+ PromptToCodeSnippetPanel.State(snippet: $0)
+ }
+ )
+ }
+ set {
+ promptToCodeState.snippets = IdentifiedArrayOf(
+ uniqueElements: newValue.map(\.snippet).reversed()
+ )
+ }
+ }
+
+ public init(
+ promptToCodeState: Shared,
+ indentSize: Int,
+ usesTabsForIndentation: Bool,
+ commandName: String? = nil,
+ isContinuous: Bool = false,
+ generateDescriptionRequirement: Bool = UserDefaults.shared
+ .value(for: \.promptToCodeGenerateDescription)
+ ) {
+ _promptToCodeState = promptToCodeState
+ self.isContinuous = isContinuous
+ self.indentSize = indentSize
+ self.usesTabsForIndentation = usesTabsForIndentation
+ self.generateDescriptionRequirement = generateDescriptionRequirement
+ self.commandName = commandName
+ focusedField = .textField
+ }
+ }
+
+ public enum Action: BindableAction {
+ case binding(BindingAction)
+ case focusOnTextField
+ case selectionRangeToggleTapped
+ case modifyCodeButtonTapped
+ case revertButtonTapped
+ case stopRespondingButtonTapped
+ case modifyCodeFinished
+ case modifyCodeCancelled
+ case cancelButtonTapped
+ case acceptButtonTapped
+ case acceptAndContinueButtonTapped
+ case appendNewLineToPromptButtonTapped
+ case snippetPanel(IdentifiedActionOf)
+ }
+
+ @Dependency(\.commandHandler) var commandHandler
+ @Dependency(\.promptToCodeService) var promptToCodeService
+ @Dependency(\.activateThisApp) var activateThisApp
+ @Dependency(\.activatePreviousActiveXcode) var activatePreviousActiveXcode
+
+ enum CancellationKey: Hashable {
+ case modifyCode(State.ID)
+ }
+
+ public var body: some ReducerOf {
+ BindingReducer()
+
+ Reduce { state, action in
+ switch action {
+ case .binding:
+ return .none
+
+ case .snippetPanel:
+ return .none
+
+ case .focusOnTextField:
+ state.focusedField = .textField
+ return .none
+
+ case .selectionRangeToggleTapped:
+ state.promptToCodeState.isAttachedToTarget.toggle()
+ return .none
+
+ case .modifyCodeButtonTapped:
+ guard !state.promptToCodeState.isGenerating else { return .none }
+ let copiedState = state
+ state.promptToCodeState.isGenerating = true
+ state.promptToCodeState.pushHistory()
+ let snippets = state.promptToCodeState.snippets
+
+ return .run { send in
+ do {
+ _ = try await withThrowingTaskGroup(of: Void.self) { group in
+ for snippet in snippets {
+ group.addTask {
+ let stream = try await promptToCodeService.modifyCode(
+ code: snippet.originalCode,
+ requirement: copiedState.promptToCodeState.instruction,
+ source: .init(
+ language: copiedState.promptToCodeState.source.language,
+ documentURL: copiedState.promptToCodeState.source
+ .documentURL,
+ projectRootURL: copiedState.promptToCodeState.source
+ .projectRootURL,
+ content: copiedState.promptToCodeState.source.content,
+ lines: copiedState.promptToCodeState.source.lines,
+ range: snippet.attachedRange
+ ),
+ isDetached: !copiedState.promptToCodeState
+ .isAttachedToTarget,
+ extraSystemPrompt: copiedState.promptToCodeState
+ .extraSystemPrompt,
+ generateDescriptionRequirement: copiedState
+ .generateDescriptionRequirement
+ ).timedDebounce(for: 0.2)
+
+ do {
+ for try await fragment in stream {
+ try Task.checkCancellation()
+ await send(.snippetPanel(.element(
+ id: snippet.id,
+ action: .modifyCodeChunkReceived(
+ code: fragment.code,
+ description: fragment.description
+ )
+ )))
+ }
+ } catch is CancellationError {
+ throw CancellationError()
+ } catch {
+ try Task.checkCancellation()
+ if (error as NSError).code == NSURLErrorCancelled {
+ await send(.snippetPanel(.element(
+ id: snippet.id,
+ action: .modifyCodeFailed(error: "Cancelled")
+ )))
+ return
+ }
+ await send(.snippetPanel(.element(
+ id: snippet.id,
+ action: .modifyCodeFailed(
+ error: error
+ .localizedDescription
+ )
+ )))
+ }
+ }
+ }
+
+ try await group.waitForAll()
+ }
+
+ await send(.modifyCodeFinished)
+ } catch is CancellationError {
+ try Task.checkCancellation()
+ await send(.modifyCodeCancelled)
+ } catch {
+ await send(.modifyCodeFinished)
+ }
+ }.cancellable(id: CancellationKey.modifyCode(state.id), cancelInFlight: true)
+
+ case .revertButtonTapped:
+ state.promptToCodeState.popHistory()
+ return .none
+
+ case .stopRespondingButtonTapped:
+ state.promptToCodeState.isGenerating = false
+ promptToCodeService.stopResponding()
+ return .cancel(id: CancellationKey.modifyCode(state.id))
+
+ case .modifyCodeFinished:
+ state.promptToCodeState.instruction = ""
+ state.promptToCodeState.isGenerating = false
+
+ if state.promptToCodeState.snippets.allSatisfy({ snippet in
+ snippet.modifiedCode.isEmpty && snippet.description.isEmpty
+ }) {
+ // if both code and description are empty, we treat it as failed
+ return .run { send in
+ await send(.revertButtonTapped)
+ }
+ }
+ return .none
+
+ case .modifyCodeCancelled:
+ state.promptToCodeState.isGenerating = false
+ return .none
+
+ case .cancelButtonTapped:
+ promptToCodeService.stopResponding()
+ return .cancel(id: CancellationKey.modifyCode(state.id))
+
+ case .acceptButtonTapped:
+ state.hasEnded = true
+ return .run { _ in
+ await commandHandler.acceptPromptToCode()
+ activatePreviousActiveXcode()
+ }
+
+ case .acceptAndContinueButtonTapped:
+ return .run { _ in
+ await commandHandler.acceptPromptToCode()
+ activateThisApp()
+ }
+
+ case .appendNewLineToPromptButtonTapped:
+ state.promptToCodeState.instruction += "\n"
+ return .none
+ }
+ }
+
+ Reduce { _, _ in .none }.forEach(\.snippetPanels, action: \.snippetPanel) {
+ PromptToCodeSnippetPanel()
+ }
+ }
+}
+
+@Reducer
+public struct PromptToCodeSnippetPanel {
+ @ObservableState
+ public struct State: Identifiable {
+ public var id: UUID { snippet.id }
+ var snippet: PromptToCodeSnippet
+ }
+
+ public enum Action {
+ case modifyCodeFinished
+ case modifyCodeChunkReceived(code: String, description: String)
+ case modifyCodeFailed(error: String)
+ case copyCodeButtonTapped
+ }
+
+ public var body: some ReducerOf {
+ Reduce { state, action in
+ switch action {
+ case .modifyCodeFinished:
+ return .none
+
+ case let .modifyCodeChunkReceived(code, description):
+ state.snippet.modifiedCode = code
+ state.snippet.description = description
+ return .none
+
+ case let .modifyCodeFailed(error):
+ state.snippet.error = error
+ return .none
+
+ case .copyCodeButtonTapped:
+ NSPasteboard.general.clearContents()
+ NSPasteboard.general.setString(state.snippet.modifiedCode, forType: .string)
+ return .none
+ }
+ }
+ }
+}
+
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift
similarity index 83%
rename from Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift
index 232f29f4..b255949c 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/SharedPanel.swift
@@ -3,16 +3,16 @@ import Preferences
import SwiftUI
@Reducer
-public struct SharedPanelFeature {
- public struct Content: Equatable {
+public struct SharedPanel {
+ public struct Content {
public var promptToCodeGroup = PromptToCodeGroup.State()
- var suggestion: CodeSuggestionProvider?
- public var promptToCode: PromptToCode.State? { promptToCodeGroup.activePromptToCode }
+ var suggestion: PresentingCodeSuggestion?
+ public var promptToCode: PromptToCodePanel.State? { promptToCodeGroup.activePromptToCode }
var error: String?
}
@ObservableState
- public struct State: Equatable {
+ public struct State {
var content: Content = .init()
var colorScheme: ColorScheme = .light
var alignTopToAnchor = false
@@ -33,7 +33,7 @@ public struct SharedPanelFeature {
}
}
- public enum Action: Equatable {
+ public enum Action {
case errorMessageCloseButtonTapped
case promptToCodeGroup(PromptToCodeGroup.Action)
}
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift
similarity index 88%
rename from Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift
index 00805391..7baef1df 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/SuggestionPanel.swift
@@ -3,10 +3,10 @@ import Foundation
import SwiftUI
@Reducer
-public struct SuggestionPanelFeature {
+public struct SuggestionPanel {
@ObservableState
public struct State: Equatable {
- var content: CodeSuggestionProvider?
+ var content: PresentingCodeSuggestion?
var colorScheme: ColorScheme = .light
var alignTopToAnchor = false
var isPanelDisplayed: Bool = false
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift
similarity index 95%
rename from Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift
index 68ecc382..b8c20fa7 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/WidgetFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/Widget.swift
@@ -10,7 +10,7 @@ import Toast
import XcodeInspector
@Reducer
-public struct WidgetFeature {
+public struct Widget {
public struct WindowState: Equatable {
var alphaValue: Double = 0
var frame: CGRect = .zero
@@ -22,7 +22,7 @@ public struct WidgetFeature {
}
@ObservableState
- public struct State: Equatable {
+ public struct State {
var focusingDocumentURL: URL?
public var colorScheme: ColorScheme = .light
@@ -30,21 +30,21 @@ public struct WidgetFeature {
// MARK: Panels
- public var panelState = PanelFeature.State()
+ public var panelState = WidgetPanel.State()
// MARK: ChatPanel
- public var chatPanelState = ChatPanelFeature.State()
+ public var chatPanelState = ChatPanel.State()
// MARK: CircularWidget
public struct CircularWidgetState: Equatable {
- var isProcessingCounters = [CircularWidgetFeature.IsProcessingCounter]()
+ var isProcessingCounters = [CircularWidget.IsProcessingCounter]()
var isProcessing: Bool = false
}
public var circularWidgetState = CircularWidgetState()
- var _internalCircularWidgetState: CircularWidgetFeature.State {
+ var _internalCircularWidgetState: CircularWidget.State {
get {
.init(
isProcessingCounters: circularWidgetState.isProcessingCounters,
@@ -90,7 +90,7 @@ public struct WidgetFeature {
case observeUserDefaults
}
- public enum Action: Equatable {
+ public enum Action {
case startup
case observeActiveApplicationChange
case observeColorSchemeChange
@@ -104,9 +104,9 @@ public struct WidgetFeature {
case updateKeyWindow(WindowCanBecomeKey)
case toastPanel(ToastPanel.Action)
- case panel(PanelFeature.Action)
- case chatPanel(ChatPanelFeature.Action)
- case circularWidget(CircularWidgetFeature.Action)
+ case panel(WidgetPanel.Action)
+ case chatPanel(ChatPanel.Action)
+ case circularWidget(CircularWidget.Action)
}
var windowsController: WidgetWindowsController? {
@@ -132,7 +132,7 @@ public struct WidgetFeature {
}
Scope(state: \._internalCircularWidgetState, action: \.circularWidget) {
- CircularWidgetFeature()
+ CircularWidget()
}
Reduce { state, action in
@@ -181,11 +181,11 @@ public struct WidgetFeature {
}
Scope(state: \.panelState, action: \.panel) {
- PanelFeature()
+ WidgetPanel()
}
Scope(state: \.chatPanelState, action: \.chatPanel) {
- ChatPanelFeature()
+ ChatPanel()
}
Reduce { state, action in
diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift
similarity index 86%
rename from Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift
rename to Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift
index 0467da4b..10d409cf 100644
--- a/Core/Sources/SuggestionWidget/FeatureReducers/PanelFeature.swift
+++ b/Core/Sources/SuggestionWidget/FeatureReducers/WidgetPanel.swift
@@ -3,10 +3,10 @@ import ComposableArchitecture
import Foundation
@Reducer
-public struct PanelFeature {
+public struct WidgetPanel {
@ObservableState
- public struct State: Equatable {
- public var content: SharedPanelFeature.Content {
+ public struct State {
+ public var content: SharedPanel.Content {
get { sharedPanelState.content }
set {
sharedPanelState.content = newValue
@@ -16,25 +16,25 @@ public struct PanelFeature {
// MARK: SharedPanel
- var sharedPanelState = SharedPanelFeature.State()
+ var sharedPanelState = SharedPanel.State()
// MARK: SuggestionPanel
- var suggestionPanelState = SuggestionPanelFeature.State()
+ var suggestionPanelState = SuggestionPanel.State()
}
- public enum Action: Equatable {
+ public enum Action {
case presentSuggestion
- case presentSuggestionProvider(CodeSuggestionProvider, displayContent: Bool)
+ case presentSuggestionProvider(PresentingCodeSuggestion, displayContent: Bool)
case presentError(String)
- case presentPromptToCode(PromptToCodeGroup.PromptToCodeInitialState)
+ case presentPromptToCode(PromptToCodePanel.State)
case displayPanelContent
case discardSuggestion
case removeDisplayedContent
case switchToAnotherEditorAndUpdateContent
- case sharedPanel(SharedPanelFeature.Action)
- case suggestionPanel(SuggestionPanelFeature.Action)
+ case sharedPanel(SharedPanel.Action)
+ case suggestionPanel(SuggestionPanel.Action)
}
@Dependency(\.suggestionWidgetControllerDependency) var suggestionWidgetControllerDependency
@@ -44,11 +44,11 @@ public struct PanelFeature {
public var body: some ReducerOf {
Scope(state: \.suggestionPanelState, action: \.suggestionPanel) {
- SuggestionPanelFeature()
+ SuggestionPanel()
}
Scope(state: \.sharedPanelState, action: \.sharedPanel) {
- SharedPanelFeature()
+ SharedPanel()
}
Reduce { state, action in
@@ -136,7 +136,7 @@ public struct PanelFeature {
}
}
- func fetchSuggestionProvider(fileURL: URL) async -> CodeSuggestionProvider? {
+ func fetchSuggestionProvider(fileURL: URL) async -> PresentingCodeSuggestion? {
guard let provider = await suggestionWidgetControllerDependency
.suggestionWidgetDataSource?
.suggestionForFile(at: fileURL) else { return nil }
diff --git a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift b/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift
deleted file mode 100644
index dd50233f..00000000
--- a/Core/Sources/SuggestionWidget/Providers/CodeSuggestionProvider.swift
+++ /dev/null
@@ -1,60 +0,0 @@
-import Combine
-import Foundation
-import Perception
-import SharedUIComponents
-import SwiftUI
-import XcodeInspector
-
-@Perceptible
-public final class CodeSuggestionProvider: Equatable {
- public static func == (lhs: CodeSuggestionProvider, rhs: CodeSuggestionProvider) -> Bool {
- lhs.code == rhs.code && lhs.language == rhs.language
- }
-
- public var code: String = ""
- public var language: String = ""
- public var startLineIndex: Int = 0
- public var suggestionCount: Int = 0
- public var currentSuggestionIndex: Int = 0
- public var extraInformation: String = ""
-
- @PerceptionIgnored public var onSelectPreviousSuggestionTapped: () -> Void
- @PerceptionIgnored public var onSelectNextSuggestionTapped: () -> Void
- @PerceptionIgnored public var onRejectSuggestionTapped: () -> Void
- @PerceptionIgnored public var onAcceptSuggestionTapped: () -> Void
- @PerceptionIgnored public var onDismissSuggestionTapped: () -> Void
-
- public init(
- code: String = "",
- language: String = "",
- startLineIndex: Int = 0,
- startCharacerIndex: Int = 0,
- suggestionCount: Int = 0,
- currentSuggestionIndex: Int = 0,
- onSelectPreviousSuggestionTapped: @escaping () -> Void = {},
- onSelectNextSuggestionTapped: @escaping () -> Void = {},
- onRejectSuggestionTapped: @escaping () -> Void = {},
- onAcceptSuggestionTapped: @escaping () -> Void = {},
- onDismissSuggestionTapped: @escaping () -> Void = {}
- ) {
- self.code = code
- self.language = language
- self.startLineIndex = startLineIndex
- self.suggestionCount = suggestionCount
- self.currentSuggestionIndex = currentSuggestionIndex
- self.onSelectPreviousSuggestionTapped = onSelectPreviousSuggestionTapped
- self.onSelectNextSuggestionTapped = onSelectNextSuggestionTapped
- self.onRejectSuggestionTapped = onRejectSuggestionTapped
- self.onAcceptSuggestionTapped = onAcceptSuggestionTapped
- self.onDismissSuggestionTapped = onDismissSuggestionTapped
- }
-
- func selectPreviousSuggestion() { onSelectPreviousSuggestionTapped() }
- func selectNextSuggestion() { onSelectNextSuggestionTapped() }
- func rejectSuggestion() { onRejectSuggestionTapped() }
- func acceptSuggestion() { onAcceptSuggestionTapped() }
- func dismissSuggestion() { onDismissSuggestionTapped() }
-
-
-}
-
diff --git a/Core/Sources/SuggestionWidget/SharedPanelView.swift b/Core/Sources/SuggestionWidget/SharedPanelView.swift
index 697a0663..1c1c1a91 100644
--- a/Core/Sources/SuggestionWidget/SharedPanelView.swift
+++ b/Core/Sources/SuggestionWidget/SharedPanelView.swift
@@ -19,7 +19,7 @@ extension View {
}
struct SharedPanelView: View {
- var store: StoreOf
+ var store: StoreOf
struct OverallState: Equatable {
var isPanelDisplayed: Bool
@@ -29,40 +29,41 @@ struct SharedPanelView: View {
}
var body: some View {
- WithPerceptionTracking {
- VStack(spacing: 0) {
- if !store.alignTopToAnchor {
- Spacer()
- .frame(minHeight: 0, maxHeight: .infinity)
- .allowsHitTesting(false)
- }
-
- DynamicContent(store: store)
+ GeometryReader { geometry in
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ if !store.alignTopToAnchor {
+ Spacer()
+ .frame(minHeight: 0, maxHeight: .infinity)
+ .allowsHitTesting(false)
+ }
- .frame(maxWidth: .infinity, maxHeight: Style.panelHeight)
- .fixedSize(horizontal: false, vertical: true)
- .allowsHitTesting(store.isPanelDisplayed)
- .frame(maxWidth: .infinity)
+ DynamicContent(store: store)
+ .frame(maxWidth: .infinity, maxHeight: geometry.size.height)
+ .fixedSize(horizontal: false, vertical: true)
+ .allowsHitTesting(store.isPanelDisplayed)
+ .layoutPriority(1)
- if store.alignTopToAnchor {
- Spacer()
- .frame(minHeight: 0, maxHeight: .infinity)
- .allowsHitTesting(false)
+ if store.alignTopToAnchor {
+ Spacer()
+ .frame(minHeight: 0, maxHeight: .infinity)
+ .allowsHitTesting(false)
+ }
}
+ .preferredColorScheme(store.colorScheme)
+ .opacity(store.opacity)
+ .animation(
+ featureFlag: \.animationBCrashSuggestion,
+ .easeInOut(duration: 0.2),
+ value: store.isPanelDisplayed
+ )
+ .frame(maxWidth: Style.panelWidth, maxHeight: .infinity)
}
- .preferredColorScheme(store.colorScheme)
- .opacity(store.opacity)
- .animation(
- featureFlag: \.animationBCrashSuggestion,
- .easeInOut(duration: 0.2),
- value: store.isPanelDisplayed
- )
- .frame(maxWidth: Style.panelWidth, maxHeight: Style.panelHeight)
}
}
struct DynamicContent: View {
- let store: StoreOf
+ let store: StoreOf
@AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode
@@ -82,7 +83,7 @@ struct SharedPanelView: View {
@ViewBuilder
func error(_ error: String) -> some View {
- ErrorPanel(description: error) {
+ ErrorPanelView(description: error) {
store.send(
.errorMessageCloseButtonTapped,
animation: .easeInOut(duration: 0.2)
@@ -96,17 +97,17 @@ struct SharedPanelView: View {
state: \.content.promptToCodeGroup.activePromptToCode,
action: \.promptToCodeGroup.activePromptToCode
) {
- PromptToCodePanel(store: store)
+ PromptToCodePanelView(store: store)
}
}
@ViewBuilder
- func suggestion(_ suggestion: CodeSuggestionProvider) -> some View {
+ func suggestion(_ suggestion: PresentingCodeSuggestion) -> some View {
switch suggestionPresentationMode {
case .nearbyTextCursor:
EmptyView()
case .floatingWidget:
- CodeBlockSuggestionPanel(suggestion: suggestion)
+ CodeBlockSuggestionPanelView(suggestion: suggestion)
}
}
}
@@ -143,7 +144,7 @@ struct SharedPanelView_Error_Preview: PreviewProvider {
colorScheme: .light,
isPanelDisplayed: true
),
- reducer: { SharedPanelFeature() }
+ reducer: { SharedPanel() }
))
.frame(width: 450, height: 200)
}
@@ -163,13 +164,15 @@ struct SharedPanelView_Both_DisplayingSuggestion_Preview: PreviewProvider {
language: "objective-c",
startLineIndex: 8,
suggestionCount: 2,
- currentSuggestionIndex: 0
+ currentSuggestionIndex: 0,
+ replacingRange: .zero,
+ replacingLines: [""]
)
),
colorScheme: .dark,
isPanelDisplayed: true
),
- reducer: { SharedPanelFeature() }
+ reducer: { SharedPanel() }
))
.frame(width: 450, height: 200)
.background {
diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift
deleted file mode 100644
index a0125683..00000000
--- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanel.swift
+++ /dev/null
@@ -1,249 +0,0 @@
-import Combine
-import Perception
-import SharedUIComponents
-import SuggestionBasic
-import SwiftUI
-import XcodeInspector
-
-struct CodeBlockSuggestionPanel: View {
- let suggestion: CodeSuggestionProvider
- @Environment(CursorPositionTracker.self) var cursorPositionTracker
- @Environment(\.colorScheme) var colorScheme
- @AppStorage(\.suggestionCodeFont) var codeFont
- @AppStorage(\.suggestionDisplayCompactMode) var suggestionDisplayCompactMode
- @AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode
- @AppStorage(\.hideCommonPrecedingSpacesInSuggestion) var hideCommonPrecedingSpaces
- @AppStorage(\.syncSuggestionHighlightTheme) var syncHighlightTheme
- @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight
- @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
- @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight
- @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
-
- struct ToolBar: View {
- let suggestion: CodeSuggestionProvider
-
- var body: some View {
- WithPerceptionTracking {
- HStack {
- Button(action: {
- suggestion.selectPreviousSuggestion()
- }) {
- Image(systemName: "chevron.left")
- }.buttonStyle(.plain)
-
- Text(
- "\(suggestion.currentSuggestionIndex + 1) / \(suggestion.suggestionCount)"
- )
- .monospacedDigit()
-
- Button(action: {
- suggestion.selectNextSuggestion()
- }) {
- Image(systemName: "chevron.right")
- }.buttonStyle(.plain)
-
- Spacer()
-
- Button(action: {
- suggestion.dismissSuggestion()
- }) {
- Text("Dismiss").foregroundStyle(.tertiary).padding(.trailing, 4)
- }.buttonStyle(.plain)
-
- Button(action: {
- suggestion.rejectSuggestion()
- }) {
- Text("Reject")
- }.buttonStyle(CommandButtonStyle(color: .gray))
-
- Button(action: {
- suggestion.acceptSuggestion()
- }) {
- Text("Accept")
- }.buttonStyle(CommandButtonStyle(color: .accentColor))
- }
- .padding()
- .foregroundColor(.secondary)
- .background(.regularMaterial)
- }
- }
- }
-
- struct CompactToolBar: View {
- let suggestion: CodeSuggestionProvider
-
- var body: some View {
- WithPerceptionTracking {
- HStack {
- Button(action: {
- suggestion.selectPreviousSuggestion()
- }) {
- Image(systemName: "chevron.left")
- }.buttonStyle(.plain)
-
- Text(
- "\(suggestion.currentSuggestionIndex + 1) / \(suggestion.suggestionCount)"
- )
- .monospacedDigit()
-
- Button(action: {
- suggestion.selectNextSuggestion()
- }) {
- Image(systemName: "chevron.right")
- }.buttonStyle(.plain)
-
- Spacer()
-
- Button(action: {
- suggestion.dismissSuggestion()
- }) {
- Image(systemName: "xmark")
- }.buttonStyle(.plain)
- }
- .padding(4)
- .font(.caption)
- .foregroundColor(.secondary)
- .background(.regularMaterial)
- }
- }
- }
-
- var body: some View {
- WithPerceptionTracking {
- VStack(spacing: 0) {
- CustomScrollView {
- WithPerceptionTracking {
- AsyncCodeBlock(
- code: suggestion.code,
- language: suggestion.language,
- startLineIndex: suggestion.startLineIndex,
- scenario: "suggestion",
- font: codeFont.value.nsFont,
- droppingLeadingSpaces: hideCommonPrecedingSpaces,
- proposedForegroundColor: {
- if syncHighlightTheme {
- if colorScheme == .light,
- let color = codeForegroundColorLight.value?.swiftUIColor
- {
- return color
- } else if let color = codeForegroundColorDark.value?
- .swiftUIColor
- {
- return color
- }
- }
- return nil
- }(),
- dimmedCharacterCount: suggestion.startLineIndex
- == cursorPositionTracker.cursorPosition.line
- ? cursorPositionTracker.cursorPosition.character
- : 0
- )
- .frame(maxWidth: .infinity)
- .background({ () -> Color in
- if syncHighlightTheme {
- if colorScheme == .light,
- let color = codeBackgroundColorLight.value?.swiftUIColor
- {
- return color
- } else if let color = codeBackgroundColorDark.value?.swiftUIColor {
- return color
- }
- }
- return Color.contentBackground
- }())
- }
- }
-
- if suggestionDisplayCompactMode {
- CompactToolBar(suggestion: suggestion)
- } else {
- ToolBar(suggestion: suggestion)
- }
- }
- .xcodeStyleFrame(cornerRadius: {
- switch suggestionPresentationMode {
- case .nearbyTextCursor: 6
- case .floatingWidget: nil
- }
- }())
- }
- }
-}
-
-// MARK: - Previews
-
-#Preview("Code Block Suggestion Panel") {
- CodeBlockSuggestionPanel(suggestion: CodeSuggestionProvider(
- code: """
- LazyVGrid(columns: [GridItem(.fixed(30)), GridItem(.flexible())]) {
- ForEach(0.. Color in
+ if syncHighlightTheme {
+ if colorScheme == .light,
+ let color = codeBackgroundColorLight.value?.swiftUIColor
+ {
+ return color
+ } else if let color = codeBackgroundColorDark.value?.swiftUIColor {
+ return color
+ }
+ }
+ return Color.contentBackground
+ }())
+ }
+ }
+
+ Description(descriptions: suggestion.descriptions)
+
+ Divider()
+
+ if suggestionDisplayCompactMode {
+ CompactToolBar(suggestion: suggestion)
+ } else {
+ ToolBar(suggestion: suggestion)
+ }
+ }
+ .xcodeStyleFrame(cornerRadius: {
+ switch suggestionPresentationMode {
+ case .nearbyTextCursor: 6
+ case .floatingWidget: nil
+ }
+ }())
+ }
+ }
+
+ @MainActor
+ func extractCode() -> (
+ code: String,
+ originalCode: String,
+ dimmedCharacterCount: AsyncCodeBlock.DimmedCharacterCount
+ ) {
+ var range = suggestion.replacingRange
+ range.end = .init(line: range.end.line - range.start.line, character: range.end.character)
+ range.start = .init(line: 0, character: range.start.character)
+ let codeInRange = EditorInformation.code(in: suggestion.replacingLines, inside: range)
+ let leftover = {
+ if range.end.line >= 0, range.end.line < suggestion.replacingLines.endIndex {
+ let lastLine = suggestion.replacingLines[range.end.line]
+ if range.end.character < lastLine.utf16.count {
+ let startIndex = lastLine.utf16.index(
+ lastLine.utf16.startIndex,
+ offsetBy: range.end.character
+ )
+ var leftover = String(lastLine.utf16.suffix(from: startIndex))
+ if leftover?.last?.isNewline ?? false {
+ leftover?.removeLast(1)
+ }
+ return leftover ?? ""
+ }
+ }
+ return ""
+ }()
+
+ let prefix = {
+ if range.start.line >= 0, range.start.line < suggestion.replacingLines.endIndex {
+ let firstLine = suggestion.replacingLines[range.start.line]
+ if range.start.character < firstLine.utf16.count {
+ let endIndex = firstLine.utf16.index(
+ firstLine.utf16.startIndex,
+ offsetBy: range.start.character
+ )
+ let prefix = String(firstLine.utf16.prefix(upTo: endIndex))
+ return prefix ?? ""
+ }
+ }
+ return ""
+ }()
+
+ let code = prefix + suggestion.code + leftover
+
+ let typedCount = suggestion.startLineIndex == textCursorTracker.cursorPosition.line
+ ? textCursorTracker.cursorPosition.character
+ : 0
+
+ return (
+ code,
+ codeInRange.code,
+ .init(prefix: typedCount, suffix: leftover.utf16.count)
+ )
+ }
+}
+
+// MARK: - Previews
+
+#Preview("Code Block Suggestion Panel") {
+ CodeBlockSuggestionPanelView(suggestion: PresentingCodeSuggestion(
+ code: """
+ LazyVGrid(columns: [GridItem(.fixed(30)), GridItem(.flexible())]) {
+ ForEach(0.. Void
diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift
deleted file mode 100644
index 682d9c79..00000000
--- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/PromptToCodePanel.swift
+++ /dev/null
@@ -1,580 +0,0 @@
-import ComposableArchitecture
-import MarkdownUI
-import SharedUIComponents
-import SuggestionBasic
-import SwiftUI
-
-struct PromptToCodePanel: View {
- let store: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- VStack(spacing: 0) {
- TopBar(store: store)
-
- Content(store: store)
- .overlay(alignment: .bottom) {
- ActionBar(store: store)
- .padding(.bottom, 8)
- }
-
- Divider()
-
- Toolbar(store: store)
- }
- .background(.ultraThickMaterial)
- .xcodeStyleFrame()
- }
- }
-}
-
-extension PromptToCodePanel {
- struct TopBar: View {
- let store: StoreOf
-
- var body: some View {
- HStack {
- SelectionRangeButton(store: store)
- Spacer()
- CopyCodeButton(store: store)
- }
- .padding(2)
- }
-
- struct SelectionRangeButton: View {
- let store: StoreOf
- var body: some View {
- WithPerceptionTracking {
- Button(action: {
- store.send(.selectionRangeToggleTapped, animation: .linear(duration: 0.1))
- }) {
- let attachedToFilename = store.filename
- let isAttached = store.isAttachedToSelectionRange
- let selectionRange = store.selectionRange
- let color: Color = isAttached ? .accentColor : .secondary.opacity(0.6)
- HStack(spacing: 4) {
- Image(
- systemName: isAttached ? "link" : "character.cursor.ibeam"
- )
- .resizable()
- .aspectRatio(contentMode: .fit)
- .frame(width: 14, height: 14)
- .frame(width: 20, height: 20, alignment: .center)
- .foregroundColor(.white)
- .background(
- color,
- in: RoundedRectangle(
- cornerRadius: 4,
- style: .continuous
- )
- )
-
- if isAttached {
- HStack(spacing: 4) {
- Text(attachedToFilename)
- .lineLimit(1)
- .truncationMode(.middle)
- if let range = selectionRange {
- Text(range.description)
- }
- }.foregroundColor(.primary)
- } else {
- Text("current selection").foregroundColor(.secondary)
- }
- }
- .padding(2)
- .padding(.trailing, 4)
- .overlay {
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .stroke(color, lineWidth: 1)
- }
- .background {
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .fill(color.opacity(0.2))
- }
- .padding(2)
- }
- .keyboardShortcut("j", modifiers: [.command])
- .buttonStyle(.plain)
- }
- }
- }
-
- struct CopyCodeButton: View {
- let store: StoreOf
- var body: some View {
- WithPerceptionTracking {
- if !store.code.isEmpty {
- CopyButton {
- store.send(.copyCodeButtonTapped)
- }
- }
- }
- }
- }
- }
-
- struct ActionBar: View {
- let store: StoreOf
-
- var body: some View {
- HStack {
- StopRespondingButton(store: store)
- ActionButtons(store: store)
- }
- }
-
- struct StopRespondingButton: View {
- let store: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- if store.isResponding {
- Button(action: {
- store.send(.stopRespondingButtonTapped)
- }) {
- HStack(spacing: 4) {
- Image(systemName: "stop.fill")
- Text("Stop")
- }
- .padding(8)
- .background(
- .regularMaterial,
- in: RoundedRectangle(cornerRadius: 6, style: .continuous)
- )
- .overlay {
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- }
- }
- .buttonStyle(.plain)
- }
- }
- }
- }
-
- struct ActionButtons: View {
- @Perception.Bindable var store: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- let isResponding = store.isResponding
- let isCodeEmpty = store.code.isEmpty
- let isDescriptionEmpty = store.description.isEmpty
- var isRespondingButCodeIsReady: Bool {
- isResponding
- && !isCodeEmpty
- && !isDescriptionEmpty
- }
- if !isResponding || isRespondingButCodeIsReady {
- HStack {
- Toggle("Continuous Mode", isOn: $store.isContinuous)
- .toggleStyle(.checkbox)
-
- Button(action: {
- store.send(.cancelButtonTapped)
- }) {
- Text("Cancel")
- }
- .buttonStyle(CommandButtonStyle(color: .gray))
- .keyboardShortcut("w", modifiers: [.command])
-
- if !isCodeEmpty {
- Button(action: {
- store.send(.acceptButtonTapped)
- }) {
- Text("Accept(⌘ + ⏎)")
- }
- .buttonStyle(CommandButtonStyle(color: .accentColor))
- .keyboardShortcut(KeyEquivalent.return, modifiers: [.command])
- }
- }
- .padding(8)
- .background(
- .regularMaterial,
- in: RoundedRectangle(cornerRadius: 6, style: .continuous)
- )
- .overlay {
- RoundedRectangle(cornerRadius: 6, style: .continuous)
- .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
- }
- }
- }
- }
- }
- }
-
- struct Content: View {
- let store: StoreOf
- @Environment(\.colorScheme) var colorScheme
- @AppStorage(\.syncPromptToCodeHighlightTheme) var syncHighlightTheme
- @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight
- @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
- @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight
- @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
-
- var codeForegroundColor: Color? {
- if syncHighlightTheme {
- if colorScheme == .light,
- let color = codeForegroundColorLight.value?.swiftUIColor
- {
- return color
- } else if let color = codeForegroundColorDark.value?.swiftUIColor {
- return color
- }
- }
- return nil
- }
-
- var codeBackgroundColor: Color {
- if syncHighlightTheme {
- if colorScheme == .light,
- let color = codeBackgroundColorLight.value?.swiftUIColor
- {
- return color
- } else if let color = codeBackgroundColorDark.value?.swiftUIColor {
- return color
- }
- }
- return Color.contentBackground
- }
-
- var body: some View {
- WithPerceptionTracking {
- ScrollView {
- VStack(spacing: 0) {
- Spacer(minLength: 60)
- ErrorMessage(store: store)
- DescriptionContent(store: store, codeForegroundColor: codeForegroundColor)
- CodeContent(store: store, codeForegroundColor: codeForegroundColor)
- }
- }
- .background(codeBackgroundColor)
- .scaleEffect(x: 1, y: -1, anchor: .center)
- }
- }
-
- struct ErrorMessage: View {
- let store: StoreOf
-
- var body: some View {
- WithPerceptionTracking {
- if let errorMessage = store.error, !errorMessage.isEmpty {
- Text(errorMessage)
- .multilineTextAlignment(.leading)
- .foregroundColor(.white)
- .padding(.horizontal, 8)
- .padding(.vertical, 4)
- .background(
- Color.red,
- in: RoundedRectangle(cornerRadius: 4, style: .continuous)
- )
- .overlay {
- RoundedRectangle(cornerRadius: 4, style: .continuous)
- .stroke(Color.primary.opacity(0.2), lineWidth: 1)
- }
- .scaleEffect(x: 1, y: -1, anchor: .center)
- }
- }
- }
- }
-
- struct DescriptionContent: View {
- let store: StoreOf
- let codeForegroundColor: Color?
-
- var body: some View {
- WithPerceptionTracking {
- if !store.description.isEmpty {
- Markdown(store.description)
- .textSelection(.enabled)
- .markdownTheme(.gitHub.text {
- BackgroundColor(Color.clear)
- ForegroundColor(codeForegroundColor)
- })
- .padding()
- .frame(maxWidth: .infinity)
- .scaleEffect(x: 1, y: -1, anchor: .center)
- }
- }
- }
- }
-
- struct CodeContent: View {
- let store: StoreOf
- let codeForegroundColor: Color?
-
- @AppStorage(\.wrapCodeInPromptToCode) var wrapCode
-
- var body: some View {
- WithPerceptionTracking {
- if store.code.isEmpty {
- Text(
- store.isResponding
- ? "Thinking..."
- : "Enter your requirement to generate code."
- )
- .foregroundColor(codeForegroundColor?.opacity(0.7) ?? .secondary)
- .padding()
- .multilineTextAlignment(.center)
- .frame(maxWidth: .infinity)
- .scaleEffect(x: 1, y: -1, anchor: .center)
- } else {
- if wrapCode {
- CodeBlockInContent(
- store: store,
- codeForegroundColor: codeForegroundColor
- )
- } else {
- ScrollView(.horizontal) {
- CodeBlockInContent(
- store: store,
- codeForegroundColor: codeForegroundColor
- )
- }
- .modify {
- if #available(macOS 13.0, *) {
- $0.scrollIndicators(.hidden)
- } else {
- $0
- }
- }
- }
- }
- }
- }
-
- struct CodeBlockInContent: View {
- let store: StoreOf
- let codeForegroundColor: Color?
-
- @Environment(\.colorScheme) var colorScheme
- @AppStorage(\.promptToCodeCodeFont) var codeFont
- @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) var hideCommonPrecedingSpaces
-
- var body: some View {
- WithPerceptionTracking {
- let startLineIndex = store.selectionRange?.start.line ?? 0
- let firstLinePrecedingSpaceCount = store.selectionRange?.start
- .character ?? 0
- CodeBlock(
- code: store.code,
- language: store.language.rawValue,
- startLineIndex: startLineIndex,
- scenario: "promptToCode",
- colorScheme: colorScheme,
- firstLinePrecedingSpaceCount: firstLinePrecedingSpaceCount,
- font: codeFont.value.nsFont,
- droppingLeadingSpaces: hideCommonPrecedingSpaces,
- proposedForegroundColor: codeForegroundColor
- )
- .frame(maxWidth: .infinity)
- .scaleEffect(x: 1, y: -1, anchor: .center)
- }
- }
- }
- }
- }
-
- struct Toolbar: View {
- let store: StoreOf
- @FocusState var focusField: PromptToCode.State.FocusField?
-
- struct RevertButtonState: Equatable {
- var isResponding: Bool
- var canRevert: Bool
- }
-
- var body: some View {
- HStack {
- RevertButton(store: store)
-
- HStack(spacing: 0) {
- InputField(store: store, focusField: $focusField)
- SendButton(store: store)
- }
- .frame(maxWidth: .infinity)
- .background {
- RoundedRectangle(cornerRadius: 6)
- .fill(Color(nsColor: .controlBackgroundColor))
- }
- .overlay {
- RoundedRectangle(cornerRadius: 6)
- .stroke(Color(nsColor: .controlColor), lineWidth: 1)
- }
- .background {
- Button(action: { store.send(.appendNewLineToPromptButtonTapped) }) {
- EmptyView()
- }
- .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift])
- }
- .background {
- Button(action: { focusField = .textField }) {
- EmptyView()
- }
- .keyboardShortcut("l", modifiers: [.command])
- }
- }
- .padding(8)
- .background(.ultraThickMaterial)
- }
-
- struct RevertButton: View {
- let store: StoreOf
- var body: some View {
- WithPerceptionTracking {
- Button(action: {
- store.send(.revertButtonTapped)
- }) {
- Group {
- Image(systemName: "arrow.uturn.backward")
- }
- .padding(6)
- .background {
- Circle().fill(Color(nsColor: .controlBackgroundColor))
- }
- .overlay {
- Circle()
- .stroke(Color(nsColor: .controlColor), lineWidth: 1)
- }
- }
- .buttonStyle(.plain)
- .disabled(store.isResponding || !store.canRevert)
- }
- }
- }
-
- struct InputField: View {
- @Perception.Bindable var store: StoreOf
- var focusField: FocusState.Binding
-
- var body: some View {
- WithPerceptionTracking {
- AutoresizingCustomTextEditor(
- text: $store.prompt,
- font: .systemFont(ofSize: 14),
- isEditable: !store.isResponding,
- maxHeight: 400,
- onSubmit: { store.send(.modifyCodeButtonTapped) }
- )
- .opacity(store.isResponding ? 0.5 : 1)
- .disabled(store.isResponding)
- .focused(focusField, equals: PromptToCode.State.FocusField.textField)
- .bind($store.focusedField, to: focusField)
- }
- .padding(8)
- .fixedSize(horizontal: false, vertical: true)
- }
- }
-
- struct SendButton: View {
- let store: StoreOf
- var body: some View {
- WithPerceptionTracking {
- Button(action: {
- store.send(.modifyCodeButtonTapped)
- }) {
- Image(systemName: "paperplane.fill")
- .padding(8)
- }
- .buttonStyle(.plain)
- .disabled(store.isResponding)
- .keyboardShortcut(KeyEquivalent.return, modifiers: [])
- }
- }
- }
- }
-}
-
-// MARK: - Previews
-
-#Preview("Default") {
- PromptToCodePanel(store: .init(initialState: .init(
- code: """
- ForEach(0..
+
+ var body: some View {
+ WithPerceptionTracking {
+ PromptToCodeCustomization.CustomizedUI(
+ state: store.$promptToCodeState,
+ isInputFieldFocused: .constant(true)
+ ) { _ in
+ VStack(spacing: 0) {
+ TopBar(store: store)
+
+ Content(store: store)
+ .overlay(alignment: .bottom) {
+ ActionBar(store: store)
+ .padding(.bottom, 8)
+ }
+
+ Divider()
+
+ Toolbar(store: store)
+ }
+ }
+ .background(.ultraThickMaterial)
+ .xcodeStyleFrame()
+ }
+ }
+}
+
+extension PromptToCodePanelView {
+ struct TopBar: View {
+ let store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ HStack {
+ SelectionRangeButton(store: store)
+ Spacer()
+ }
+ .padding(2)
+
+ Divider()
+
+ if let previousStep = store.promptToCodeState.history.last {
+ Button(action: {
+ store.send(.revertButtonTapped)
+ }, label: {
+ HStack(spacing: 4) {
+ Text(Image(systemName: "arrow.uturn.backward.circle.fill"))
+ .foregroundStyle(.secondary)
+ Text(previousStep.instruction)
+ .lineLimit(1)
+ .truncationMode(.tail)
+ .foregroundStyle(.secondary)
+ Spacer()
+ }
+ .contentShape(Rectangle())
+ })
+ .buttonStyle(.plain)
+ .disabled(store.promptToCodeState.isGenerating)
+ .padding(6)
+
+ Divider()
+ }
+ }
+ .animation(.linear(duration: 0.1), value: store.promptToCodeState.history.count)
+ }
+ }
+
+ struct SelectionRangeButton: View {
+ let store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ Button(action: {
+ store.send(.selectionRangeToggleTapped, animation: .linear(duration: 0.1))
+ }) {
+ let attachedToFilename = store.filename
+ let isAttached = store.promptToCodeState.isAttachedToTarget
+ let color: Color = isAttached ? .accentColor : .secondary.opacity(0.6)
+ HStack(spacing: 4) {
+ Image(
+ systemName: isAttached ? "link" : "character.cursor.ibeam"
+ )
+ .resizable()
+ .aspectRatio(contentMode: .fit)
+ .frame(width: 14, height: 14)
+ .frame(width: 20, height: 20, alignment: .center)
+ .foregroundColor(.white)
+ .background(
+ color,
+ in: RoundedRectangle(
+ cornerRadius: 4,
+ style: .continuous
+ )
+ )
+
+ if isAttached {
+ HStack(spacing: 4) {
+ Text(attachedToFilename)
+ .lineLimit(1)
+ .truncationMode(.middle)
+ }.foregroundColor(.primary)
+ } else {
+ Text("current selection").foregroundColor(.secondary)
+ }
+ }
+ .padding(2)
+ .padding(.trailing, 4)
+ .overlay {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .stroke(color, lineWidth: 1)
+ }
+ .background {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .fill(color.opacity(0.2))
+ }
+ .padding(2)
+ }
+ .keyboardShortcut("j", modifiers: [.command])
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ struct ActionBar: View {
+ let store: StoreOf
+
+ var body: some View {
+ HStack {
+ StopRespondingButton(store: store)
+ ActionButtons(store: store)
+ }
+ }
+
+ struct StopRespondingButton: View {
+ let store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ if store.promptToCodeState.isGenerating {
+ Button(action: {
+ store.send(.stopRespondingButtonTapped)
+ }) {
+ HStack(spacing: 4) {
+ Image(systemName: "stop.fill")
+ Text("Stop")
+ }
+ .padding(8)
+ .background(
+ .regularMaterial,
+ in: RoundedRectangle(cornerRadius: 6, style: .continuous)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ }
+ .buttonStyle(.plain)
+ }
+ }
+ }
+ }
+
+ struct ActionButtons: View {
+ @Perception.Bindable var store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ let isResponding = store.promptToCodeState.isGenerating
+ let isCodeEmpty = store.promptToCodeState.snippets
+ .allSatisfy(\.modifiedCode.isEmpty)
+ let isDescriptionEmpty = store.promptToCodeState.snippets
+ .allSatisfy(\.description.isEmpty)
+ var isRespondingButCodeIsReady: Bool {
+ isResponding
+ && !isCodeEmpty
+ && !isDescriptionEmpty
+ }
+ if !isResponding || isRespondingButCodeIsReady {
+ HStack {
+ Menu {
+ WithPerceptionTracking {
+ Toggle(
+ "Always accept and continue",
+ isOn: $store.isContinuous
+ .animation(.easeInOut(duration: 0.1))
+ )
+ .toggleStyle(.checkbox)
+ }
+ } label: {
+ Image(systemName: "gearshape.fill")
+ .resizable()
+ .scaledToFit()
+ .foregroundStyle(.secondary)
+ .frame(width: 16)
+ .frame(maxHeight: .infinity)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+
+ Button(action: {
+ store.send(.cancelButtonTapped)
+ }) {
+ Text("Cancel")
+ }
+ .buttonStyle(CommandButtonStyle(color: .gray))
+ .keyboardShortcut("w", modifiers: [.command])
+
+ if !isCodeEmpty {
+ AcceptButton(store: store)
+ }
+ }
+ .fixedSize()
+ .padding(8)
+ .background(
+ .regularMaterial,
+ in: RoundedRectangle(cornerRadius: 6, style: .continuous)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 6, style: .continuous)
+ .stroke(Color(nsColor: .separatorColor), lineWidth: 1)
+ }
+ .animation(
+ .easeInOut(duration: 0.1),
+ value: store.promptToCodeState.snippets
+ )
+ }
+ }
+ }
+ }
+
+ struct AcceptButton: View {
+ let store: StoreOf
+ @Environment(\.modifierFlags) var modifierFlags
+
+ struct TheButtonStyle: ButtonStyle {
+ func makeBody(configuration: Configuration) -> some View {
+ configuration.label
+ .background(
+ Rectangle()
+ .fill(Color.accentColor.opacity(configuration.isPressed ? 0.8 : 1))
+ )
+ }
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ let defaultModeIsContinuous = store.isContinuous
+ let isAttached = store.promptToCodeState.isAttachedToTarget
+
+ HStack(spacing: 0) {
+ Button(action: {
+ switch (
+ modifierFlags.contains(.option),
+ defaultModeIsContinuous
+ ) {
+ case (true, true):
+ store.send(.acceptButtonTapped)
+ case (false, true):
+ store.send(.acceptAndContinueButtonTapped)
+ case (true, false):
+ store.send(.acceptAndContinueButtonTapped)
+ case (false, false):
+ store.send(.acceptButtonTapped)
+ }
+ }) {
+ Group {
+ switch (
+ isAttached,
+ modifierFlags.contains(.option),
+ defaultModeIsContinuous
+ ) {
+ case (true, true, true):
+ Text("Accept(⌥ + ⌘ + ⏎)")
+ case (true, false, true):
+ Text("Accept and Continue(⌘ + ⏎)")
+ case (true, true, false):
+ Text("Accept and Continue(⌥ + ⌘ + ⏎)")
+ case (true, false, false):
+ Text("Accept(⌘ + ⏎)")
+ case (false, true, true):
+ Text("Replace(⌥ + ⌘ + ⏎)")
+ case (false, false, true):
+ Text("Replace and Continue(⌘ + ⏎)")
+ case (false, true, false):
+ Text("Replace and Continue(⌥ + ⌘ + ⏎)")
+ case (false, false, false):
+ Text("Replace(⌘ + ⏎)")
+ }
+ }
+ .padding(.vertical, 4)
+ .padding(.leading, 8)
+ .padding(.trailing, 4)
+ }
+ .buttonStyle(TheButtonStyle())
+ .keyboardShortcut(
+ KeyEquivalent.return,
+ modifiers: modifierFlags
+ .contains(.option) ? [.command, .option] : [.command]
+ )
+
+ Divider()
+
+ Menu {
+ WithPerceptionTracking {
+ if defaultModeIsContinuous {
+ Button(action: {
+ store.send(.acceptButtonTapped)
+ }) {
+ Text("Accept(⌥ + ⌘ + ⏎)")
+ }
+ } else {
+ Button(action: {
+ store.send(.acceptAndContinueButtonTapped)
+ }) {
+ Text("Accept and Continue(⌥ + ⌘ + ⏎)")
+ }
+ }
+ }
+ } label: {
+ Text(Image(systemName: "chevron.down"))
+ .font(.footnote.weight(.bold))
+ .scaleEffect(0.8)
+ .foregroundStyle(.white.opacity(0.8))
+ .frame(maxHeight: .infinity)
+ .padding(.leading, 1)
+ .padding(.trailing, 2)
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ }
+ .fixedSize()
+
+ .foregroundColor(.white)
+ .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous))
+ .background(
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .fill(Color.accentColor)
+ )
+ .overlay {
+ RoundedRectangle(cornerRadius: 4, style: .continuous)
+ .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1))
+ }
+ }
+ }
+ }
+ }
+
+ struct Content: View {
+ let store: StoreOf
+
+ @Environment(\.colorScheme) var colorScheme
+ @AppStorage(\.syncPromptToCodeHighlightTheme) var syncHighlightTheme
+ @AppStorage(\.codeForegroundColorLight) var codeForegroundColorLight
+ @AppStorage(\.codeForegroundColorDark) var codeForegroundColorDark
+ @AppStorage(\.codeBackgroundColorLight) var codeBackgroundColorLight
+ @AppStorage(\.codeBackgroundColorDark) var codeBackgroundColorDark
+
+ var codeForegroundColor: Color? {
+ if syncHighlightTheme {
+ if colorScheme == .light,
+ let color = codeForegroundColorLight.value?.swiftUIColor
+ {
+ return color
+ } else if let color = codeForegroundColorDark.value?.swiftUIColor {
+ return color
+ }
+ }
+ return nil
+ }
+
+ var codeBackgroundColor: Color {
+ if syncHighlightTheme {
+ if colorScheme == .light,
+ let color = codeBackgroundColorLight.value?.swiftUIColor
+ {
+ return color
+ } else if let color = codeBackgroundColorDark.value?.swiftUIColor {
+ return color
+ }
+ }
+ return Color.contentBackground
+ }
+
+ var body: some View {
+ WithPerceptionTracking {
+ ScrollView {
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ Spacer(minLength: 56)
+
+ VStack(spacing: 0) {
+ let language = store.promptToCodeState.source.language
+ let isAttached = store.promptToCodeState.isAttachedToTarget
+ let lastId = store.promptToCodeState.snippets.last?.id
+ let isGenerating = store.promptToCodeState.isGenerating
+ ForEach(store.scope(
+ state: \.snippetPanels,
+ action: \.snippetPanel
+ )) { snippetStore in
+ WithPerceptionTracking {
+ if snippetStore.id != lastId {
+ Divider()
+ }
+
+ SnippetPanelView(
+ store: snippetStore,
+ language: language,
+ codeForegroundColor: codeForegroundColor ?? .primary,
+ codeBackgroundColor: codeBackgroundColor,
+ isAttached: isAttached,
+ isGenerating: isGenerating
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ .background(codeBackgroundColor)
+ .scaleEffect(x: 1, y: -1, anchor: .center)
+ }
+ }
+
+ struct SnippetPanelView: View {
+ let store: StoreOf
+ let language: CodeLanguage
+ let codeForegroundColor: Color
+ let codeBackgroundColor: Color
+ let isAttached: Bool
+ let isGenerating: Bool
+
+ var body: some View {
+ WithPerceptionTracking {
+ VStack(spacing: 0) {
+ ErrorMessage(store: store)
+ DescriptionContent(store: store, codeForegroundColor: codeForegroundColor)
+ CodeContent(
+ store: store,
+ language: language,
+ isGenerating: isGenerating,
+ codeForegroundColor: codeForegroundColor
+ )
+ SnippetTitleBar(
+ store: store,
+ language: language,
+ codeForegroundColor: codeForegroundColor,
+ isAttached: isAttached
+ )
+ }
+ }
+ }
+ }
+
+ struct SnippetTitleBar: View {
+ let store: StoreOf
+ let language: CodeLanguage
+ let codeForegroundColor: Color
+ let isAttached: Bool
+ var body: some View {
+ WithPerceptionTracking {
+ HStack {
+ Text(language.rawValue)
+ .foregroundStyle(codeForegroundColor)
+ .font(.callout.bold())
+ .lineLimit(1)
+ if isAttached {
+ Text(String(describing: store.snippet.attachedRange))
+ .foregroundStyle(codeForegroundColor.opacity(0.5))
+ .font(.callout)
+ }
+ Spacer()
+ CopyCodeButton(store: store)
+ }
+ .padding(.leading, 8)
+ .scaleEffect(x: 1, y: -1, anchor: .center)
+ }
+ }
+ }
+
+ struct CopyCodeButton: View {
+ let store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ if !store.snippet.modifiedCode.isEmpty {
+ CopyButton {
+ store.send(.copyCodeButtonTapped)
+ }
+ }
+ }
+ }
+ }
+
+ struct ErrorMessage: View {
+ let store: StoreOf
+
+ var body: some View {
+ WithPerceptionTracking {
+ if let errorMessage = store.snippet.error, !errorMessage.isEmpty {
+ (
+ Text(Image(systemName: "exclamationmark.triangle.fill")) +
+ Text(" ") +
+ Text(errorMessage)
+ )
+ .multilineTextAlignment(.leading)
+ .foregroundColor(.red)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .scaleEffect(x: 1, y: -1, anchor: .center)
+ }
+ }
+ }
+ }
+
+ struct DescriptionContent: View {
+ let store: StoreOf
+ let codeForegroundColor: Color?
+
+ var body: some View {
+ WithPerceptionTracking {
+ if !store.snippet.description.isEmpty {
+ Markdown(store.snippet.description)
+ .textSelection(.enabled)
+ .markdownTheme(.gitHub.text {
+ BackgroundColor(Color.clear)
+ ForegroundColor(codeForegroundColor)
+ })
+ .padding(.horizontal)
+ .padding(.vertical, 4)
+ .frame(maxWidth: .infinity)
+ .scaleEffect(x: 1, y: -1, anchor: .center)
+ }
+ }
+ }
+ }
+
+ struct CodeContent: View {
+ let store: StoreOf
+ let language: CodeLanguage
+ let isGenerating: Bool
+ let codeForegroundColor: Color?
+
+ @AppStorage(\.wrapCodeInPromptToCode) var wrapCode
+
+ var body: some View {
+ WithPerceptionTracking {
+ if !store.snippet.modifiedCode.isEmpty {
+ let wrapCode = wrapCode ||
+ [CodeLanguage.plaintext, .builtIn(.markdown), .builtIn(.shellscript),
+ .builtIn(.tex)].contains(language)
+ if wrapCode {
+ CodeBlockInContent(
+ store: store,
+ language: language,
+ codeForegroundColor: codeForegroundColor
+ )
+ } else {
+ ScrollView(.horizontal) {
+ CodeBlockInContent(
+ store: store,
+ language: language,
+ codeForegroundColor: codeForegroundColor
+ )
+ }
+ .modify {
+ if #available(macOS 13.0, *) {
+ $0.scrollIndicators(.hidden)
+ } else {
+ $0
+ }
+ }
+ }
+ } else {
+ if isGenerating {
+ Text("Thinking...")
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .scaleEffect(x: 1, y: -1, anchor: .center)
+ } else {
+ Text("Enter your requirements to generate code.")
+ .foregroundStyle(.secondary)
+ .padding(.horizontal, 8)
+ .padding(.vertical, 4)
+ .scaleEffect(x: 1, y: -1, anchor: .center)
+ }
+ }
+ }
+ }
+
+ struct CodeBlockInContent: View {
+ let store: StoreOf
+ let language: CodeLanguage
+ let codeForegroundColor: Color?
+
+ @Environment(\.colorScheme) var colorScheme
+ @AppStorage(\.promptToCodeCodeFont) var codeFont
+ @AppStorage(\.hideCommonPrecedingSpacesInPromptToCode) var hideCommonPrecedingSpaces
+
+ var body: some View {
+ WithPerceptionTracking {
+ let startLineIndex = store.snippet.attachedRange.start.line
+ AsyncCodeBlock(
+ code: store.snippet.modifiedCode,
+ originalCode: store.snippet.originalCode,
+ language: language.rawValue,
+ startLineIndex: startLineIndex,
+ scenario: "promptToCode",
+ font: codeFont.value.nsFont,
+ droppingLeadingSpaces: hideCommonPrecedingSpaces,
+ proposedForegroundColor: codeForegroundColor,
+ ignoreWholeLineChangeInDiff: false
+ )
+ .frame(maxWidth: .infinity)
+
+ .scaleEffect(x: 1, y: -1, anchor: .center)
+ }
+ }
+ }
+ }
+ }
+
+ struct Toolbar: View {
+ let store: StoreOf
+ @FocusState var focusField: PromptToCodePanel.State.FocusField?
+
+ var body: some View {
+ HStack {
+ HStack(spacing: 0) {
+ InputField(store: store, focusField: $focusField)
+ SendButton(store: store)
+ }
+ .frame(maxWidth: .infinity)
+ .background {
+ RoundedRectangle(cornerRadius: 6)
+ .fill(Color(nsColor: .controlBackgroundColor))
+ }
+ .overlay {
+ RoundedRectangle(cornerRadius: 6)
+ .stroke(Color(nsColor: .controlColor), lineWidth: 1)
+ }
+ .background {
+ Button(action: { store.send(.appendNewLineToPromptButtonTapped) }) {
+ EmptyView()
+ }
+ .keyboardShortcut(KeyEquivalent.return, modifiers: [.shift])
+ }
+ .background {
+ Button(action: { focusField = .textField }) {
+ EmptyView()
+ }
+ .keyboardShortcut("l", modifiers: [.command])
+ }
+ }
+ .padding(8)
+ .background(.ultraThickMaterial)
+ }
+
+ struct InputField: View {
+ @Perception.Bindable var store: StoreOf
+ var focusField: FocusState.Binding
+
+ var body: some View {
+ WithPerceptionTracking {
+ AutoresizingCustomTextEditor(
+ text: $store.promptToCodeState.instruction,
+ font: .systemFont(ofSize: 14),
+ isEditable: !store.promptToCodeState.isGenerating,
+ maxHeight: 400,
+ onSubmit: { store.send(.modifyCodeButtonTapped) }
+ )
+ .opacity(store.promptToCodeState.isGenerating ? 0.5 : 1)
+ .disabled(store.promptToCodeState.isGenerating)
+ .focused(focusField, equals: PromptToCodePanel.State.FocusField.textField)
+ .bind($store.focusedField, to: focusField)
+ }
+ .padding(8)
+ .fixedSize(horizontal: false, vertical: true)
+ }
+ }
+
+ struct SendButton: View {
+ let store: StoreOf
+ var body: some View {
+ WithPerceptionTracking {
+ Button(action: {
+ store.send(.modifyCodeButtonTapped)
+ }) {
+ Image(systemName: "paperplane.fill")
+ .padding(8)
+ }
+ .buttonStyle(.plain)
+ .disabled(store.promptToCodeState.isGenerating)
+ .keyboardShortcut(KeyEquivalent.return, modifiers: [])
+ }
+ }
+ }
+ }
+}
+
+// MARK: - Previews
+
+#Preview("Multiple Snippets") {
+ PromptToCodePanelView(store: .init(initialState: .init(
+ promptToCodeState: Shared(PromptToCodeState(
+ source: .init(
+ language: CodeLanguage.builtIn(.swift),
+ documentURL: URL(
+ fileURLWithPath: "path/to/file-name-is-super-long-what-should-we-do-with-it-hah-longer-longer.txt"
+ ),
+ projectRootURL: URL(fileURLWithPath: "path/to/file.txt"),
+ content: "",
+ lines: []
+ ),
+ history: [
+ .init(snippets: [
+ .init(
+ startLineIndex: 8,
+ originalCode: "print(foo)",
+ modifiedCode: "print(bar)",
+ description: "",
+ error: "Error",
+ attachedRange: CursorRange(
+ start: .init(line: 8, character: 0),
+ end: .init(line: 12, character: 2)
+ )
+ ),
+ ], instruction: "Previous instruction"),
+ ],
+ snippets: [
+ .init(
+ startLineIndex: 8,
+ originalCode: "print(foo)",
+ modifiedCode: "print(bar)\nprint(baz)",
+ description: "",
+ error: "Error",
+ attachedRange: CursorRange(
+ start: .init(line: 8, character: 0),
+ end: .init(line: 12, character: 2)
+ )
+ ),
+ .init(
+ startLineIndex: 13,
+ originalCode: """
+ struct Foo {
+ var foo: Int
+ }
+ """,
+ modifiedCode: """
+ struct Bar {
+ var bar: String
+ }
+ """,
+ description: "Cool",
+ error: nil,
+ attachedRange: CursorRange(
+ start: .init(line: 13, character: 0),
+ end: .init(line: 12, character: 2)
+ )
+ ),
+ ],
+ instruction: "",
+ extraSystemPrompt: "",
+ isAttachedToTarget: true
+ )),
+ indentSize: 4,
+ usesTabsForIndentation: false,
+ commandName: "Generate Code"
+ ), reducer: { PromptToCodePanel() }))
+ .frame(maxWidth: 450, maxHeight: Style.panelHeight)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(width: 500, height: 500, alignment: .center)
+}
+
+#Preview("Detached With Long File Name") {
+ PromptToCodePanelView(store: .init(initialState: .init(
+ promptToCodeState: Shared(PromptToCodeState(
+ source: .init(
+ language: CodeLanguage.builtIn(.swift),
+ documentURL: URL(
+ fileURLWithPath: "path/to/file-name-is-super-long-what-should-we-do-with-it-hah.txt"
+ ),
+ projectRootURL: URL(fileURLWithPath: "path/to/file.txt"),
+ content: "",
+ lines: []
+ ),
+ snippets: [
+ .init(
+ startLineIndex: 8,
+ originalCode: "print(foo)",
+ modifiedCode: "print(bar)",
+ description: "",
+ error: "Error",
+ attachedRange: CursorRange(
+ start: .init(line: 8, character: 0),
+ end: .init(line: 12, character: 2)
+ )
+ ),
+ .init(
+ startLineIndex: 13,
+ originalCode: """
+ struct Bar {
+ var foo: Int
+ }
+ """,
+ modifiedCode: """
+ struct Bar {
+ var foo: String
+ }
+ """,
+ description: "Cool",
+ error: nil,
+ attachedRange: CursorRange(
+ start: .init(line: 13, character: 0),
+ end: .init(line: 12, character: 2)
+ )
+ ),
+ ],
+ instruction: "",
+ extraSystemPrompt: "",
+ isAttachedToTarget: false
+ )),
+ indentSize: 4,
+ usesTabsForIndentation: false,
+ commandName: "Generate Code"
+ ), reducer: { PromptToCodePanel() }))
+ .frame(maxWidth: 450, maxHeight: Style.panelHeight)
+ .fixedSize(horizontal: false, vertical: true)
+ .frame(width: 500, height: 500, alignment: .center)
+}
+
diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift
index 5cd6ba23..c7aca342 100644
--- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/ToastPanelView.swift
@@ -12,34 +12,92 @@ struct ToastPanelView: View {
VStack(spacing: 4) {
if !store.alignTopToAnchor {
Spacer()
+ .allowsHitTesting(false)
}
ForEach(store.toast.messages) { message in
- message.content
- .foregroundColor(.white)
- .padding(8)
- .frame(maxWidth: .infinity)
- .background({
- switch message.type {
- case .info: return Color.accentColor
- case .error: return Color(nsColor: .systemRed)
- case .warning: return Color(nsColor: .systemOrange)
+ HStack {
+ message.content
+ .foregroundColor(.white)
+ .textSelection(.enabled)
+
+
+ if !message.buttons.isEmpty {
+ HStack {
+ ForEach(
+ Array(message.buttons.enumerated()),
+ id: \.offset
+ ) { _, button in
+ Button(action: button.action) {
+ button.label
+ .foregroundColor(.white)
+ .padding(.horizontal, 6)
+ .padding(.vertical, 2)
+ .background {
+ RoundedRectangle(cornerRadius: 4)
+ .stroke(Color.white, lineWidth: 1)
+ }
+ .contentShape(Rectangle())
+ }
+ .buttonStyle(.plain)
+ .allowsHitTesting(true)
+ }
}
- }() as Color, in: RoundedRectangle(cornerRadius: 8))
- .overlay {
- RoundedRectangle(cornerRadius: 8)
- .stroke(Color.black.opacity(0.1), lineWidth: 1)
}
+ }
+ .padding(8)
+ .frame(maxWidth: .infinity)
+ .background({
+ switch message.type {
+ case .info: return Color.accentColor
+ case .error: return Color(nsColor: .systemRed)
+ case .warning: return Color(nsColor: .systemOrange)
+ }
+ }() as Color, in: RoundedRectangle(cornerRadius: 8))
+ .overlay {
+ RoundedRectangle(cornerRadius: 8)
+ .stroke(Color.black.opacity(0.1), lineWidth: 1)
+ }
}
if store.alignTopToAnchor {
Spacer()
+ .allowsHitTesting(false)
}
}
.colorScheme(store.colorScheme)
.frame(maxWidth: .infinity, maxHeight: .infinity)
- .allowsHitTesting(false)
}
}
}
+#Preview {
+ ToastPanelView(store: .init(initialState: .init(
+ toast: .init(messages: [
+ ToastController.Message(
+ id: UUID(),
+ type: .info,
+ content: Text("Info message"),
+ buttons: [
+ .init(label: Text("Dismiss"), action: {}),
+ .init(label: Text("More info"), action: {}),
+ ]
+ ),
+ ToastController.Message(
+ id: UUID(),
+ type: .error,
+ content: Text("Error message"),
+ buttons: [.init(label: Text("Dismiss"), action: {})]
+ ),
+ ToastController.Message(
+ id: UUID(),
+ type: .warning,
+ content: Text("Warning message"),
+ buttons: [.init(label: Text("Dismiss"), action: {})]
+ ),
+ ])
+ ), reducer: {
+ ToastPanel()
+ }))
+}
+
diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift
index a1b0f425..b25eb0e9 100644
--- a/Core/Sources/SuggestionWidget/SuggestionPanelView.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionPanelView.swift
@@ -3,7 +3,7 @@ import Foundation
import SwiftUI
struct SuggestionPanelView: View {
- let store: StoreOf
+ let store: StoreOf
struct OverallState: Equatable {
var isPanelDisplayed: Bool
@@ -54,7 +54,7 @@ struct SuggestionPanelView: View {
}
struct Content: View {
- let store: StoreOf
+ let store: StoreOf
@AppStorage(\.suggestionPresentationMode) var suggestionPresentationMode
var body: some View {
@@ -63,7 +63,7 @@ struct SuggestionPanelView: View {
ZStack(alignment: .topLeading) {
switch suggestionPresentationMode {
case .nearbyTextCursor:
- CodeBlockSuggestionPanel(suggestion: content)
+ CodeBlockSuggestionPanelView(suggestion: content)
case .floatingWidget:
EmptyView()
}
diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift
index ab15d53b..09a0ae7a 100644
--- a/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionWidgetController.swift
@@ -11,7 +11,7 @@ import XcodeInspector
@MainActor
public final class SuggestionWidgetController: NSObject {
- let store: StoreOf
+ let store: StoreOf
let chatTabPool: ChatTabPool
let windowsController: WidgetWindowsController
private var cancellable = Set()
@@ -19,7 +19,7 @@ public final class SuggestionWidgetController: NSObject {
public let dependency: SuggestionWidgetControllerDependency
public init(
- store: StoreOf,
+ store: StoreOf,
chatTabPool: ChatTabPool,
dependency: SuggestionWidgetControllerDependency
) {
@@ -70,17 +70,5 @@ public extension SuggestionWidgetController {
func presentError(_ errorDescription: String) {
store.send(.toastPanel(.toast(.toast(errorDescription, .error, nil))))
}
-
- func presentChatRoom() {
- store.send(.chatPanel(.presentChatPanel(forceDetach: false)))
- }
-
- func presentDetachedGlobalChat() {
- store.send(.chatPanel(.presentChatPanel(forceDetach: true)))
- }
-
- func closeChatRoom() {
-// store.send(.chatPanel(.closeChatPanel))
- }
}
diff --git a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift
index f7ad662a..2269d095 100644
--- a/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift
+++ b/Core/Sources/SuggestionWidget/SuggestionWidgetDataSource.swift
@@ -1,12 +1,12 @@
import Foundation
public protocol SuggestionWidgetDataSource {
- func suggestionForFile(at url: URL) async -> CodeSuggestionProvider?
+ func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion?
}
struct MockWidgetDataSource: SuggestionWidgetDataSource {
- func suggestionForFile(at url: URL) async -> CodeSuggestionProvider? {
- return CodeSuggestionProvider(
+ func suggestionForFile(at url: URL) async -> PresentingCodeSuggestion? {
+ return PresentingCodeSuggestion(
code: """
func test() {
let x = 1
@@ -17,7 +17,9 @@ struct MockWidgetDataSource: SuggestionWidgetDataSource {
language: "swift",
startLineIndex: 1,
suggestionCount: 3,
- currentSuggestionIndex: 0
+ currentSuggestionIndex: 0,
+ replacingRange: .zero,
+ replacingLines: []
)
}
}
diff --git a/Core/Sources/SuggestionWidget/CursorPositionTracker.swift b/Core/Sources/SuggestionWidget/TextCursorTracker.swift
similarity index 52%
rename from Core/Sources/SuggestionWidget/CursorPositionTracker.swift
rename to Core/Sources/SuggestionWidget/TextCursorTracker.swift
index 35f74326..f73511a7 100644
--- a/Core/Sources/SuggestionWidget/CursorPositionTracker.swift
+++ b/Core/Sources/SuggestionWidget/TextCursorTracker.swift
@@ -3,11 +3,31 @@ import Foundation
import Perception
import SuggestionBasic
import XcodeInspector
+import SwiftUI
+/// A passive tracker that observe the changes of the source editor content.
@Perceptible
-final class CursorPositionTracker {
+final class TextCursorTracker {
@MainActor
- var cursorPosition: CursorPosition = .zero
+ var cursorPosition: CursorPosition { content.cursorPosition }
+ @MainActor
+ var currentLine: String {
+ if content.cursorPosition.line >= 0, content.cursorPosition.line < content.lines.count {
+ content.lines[content.cursorPosition.line]
+ } else {
+ ""
+ }
+ }
+
+ @MainActor
+ var content: SourceEditor.Content = .init(
+ content: "",
+ lines: [],
+ selections: [],
+ cursorPosition: .zero,
+ cursorOffset: 0,
+ lineAnnotations: []
+ )
@PerceptionIgnored var editorObservationTask: Set = []
@PerceptionIgnored var eventObservationTask: Task?
@@ -19,8 +39,13 @@ final class CursorPositionTracker {
deinit {
eventObservationTask?.cancel()
}
+
+ var isPreview: Bool {
+ ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] == "1"
+ }
private func observeAppChange() {
+ if isPreview { return }
editorObservationTask = []
Task {
await XcodeInspector.shared.safe.$focusedEditor.sink { [weak self] editor in
@@ -33,10 +58,11 @@ final class CursorPositionTracker {
}
private func observeAXNotifications(_ editor: SourceEditor) {
+ if isPreview { return }
eventObservationTask?.cancel()
let content = editor.getLatestEvaluatedContent()
Task { @MainActor in
- self.cursorPosition = content.cursorPosition
+ self.content = content
}
eventObservationTask = Task { [weak self] in
for await event in await editor.axNotifications.notifications() {
@@ -44,10 +70,20 @@ final class CursorPositionTracker {
guard event.kind == .evaluatedContentChanged else { continue }
let content = editor.getLatestEvaluatedContent()
Task { @MainActor in
- self.cursorPosition = content.cursorPosition
+ self.content = content
}
}
}
}
}
+struct TextCursorTrackerEnvironmentKey: EnvironmentKey {
+ static var defaultValue: TextCursorTracker = .init()
+}
+
+extension EnvironmentValues {
+ var textCursorTracker: TextCursorTracker {
+ get { self[TextCursorTrackerEnvironmentKey.self] }
+ set { self[TextCursorTrackerEnvironmentKey.self] = newValue }
+ }
+}
diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
index 41f6c17c..b7ceb487 100644
--- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
+++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift
@@ -9,6 +9,7 @@ public struct WidgetLocation: Equatable {
var widgetFrame: CGRect
var tabFrame: CGRect
+ var sharedPanelLocation: PanelLocation
var defaultPanelLocation: PanelLocation
var suggestionPanelLocation: PanelLocation?
}
@@ -70,7 +71,7 @@ enum UpdateLocationStrategy {
.value(for: \.preferWidgetToStayInsideEditorWhenWidthGreaterThan),
editorFrameExpendedSize: CGSize = .zero
) -> WidgetLocation {
- return HorizontalMovable().framesForWindows(
+ var frames = HorizontalMovable().framesForWindows(
y: mainScreen.frame.height - editorFrame.maxY + Style.widgetPadding,
alignPanelTopToAnchor: false,
editorFrame: editorFrame,
@@ -80,6 +81,16 @@ enum UpdateLocationStrategy {
hideCircularWidget: hideCircularWidget,
editorFrameExpendedSize: editorFrameExpendedSize
)
+
+ frames.sharedPanelLocation.frame.size.height = max(
+ frames.defaultPanelLocation.frame.height,
+ editorFrame.height - Style.widgetHeight
+ )
+ frames.defaultPanelLocation.frame.size.height = max(
+ frames.defaultPanelLocation.frame.height,
+ (editorFrame.height - Style.widgetHeight) / 2
+ )
+ return frames
}
}
@@ -155,6 +166,10 @@ enum UpdateLocationStrategy {
return .init(
widgetFrame: widgetFrameOnTheRightSide,
tabFrame: tabFrame,
+ sharedPanelLocation: .init(
+ frame: panelFrame,
+ alignPanelTop: alignPanelTopToAnchor
+ ),
defaultPanelLocation: .init(
frame: panelFrame,
alignPanelTop: alignPanelTopToAnchor
@@ -214,6 +229,10 @@ enum UpdateLocationStrategy {
return .init(
widgetFrame: widgetFrameOnTheLeftSide,
tabFrame: tabFrame,
+ sharedPanelLocation: .init(
+ frame: panelFrame,
+ alignPanelTop: alignPanelTopToAnchor
+ ),
defaultPanelLocation: .init(
frame: panelFrame,
alignPanelTop: alignPanelTopToAnchor
@@ -241,6 +260,10 @@ enum UpdateLocationStrategy {
return .init(
widgetFrame: widgetFrameOnTheRightSide,
tabFrame: tabFrame,
+ sharedPanelLocation: .init(
+ frame: panelFrame,
+ alignPanelTop: alignPanelTopToAnchor
+ ),
defaultPanelLocation: .init(
frame: panelFrame,
alignPanelTop: alignPanelTopToAnchor
diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift
index 42837b00..23494685 100644
--- a/Core/Sources/SuggestionWidget/WidgetView.swift
+++ b/Core/Sources/SuggestionWidget/WidgetView.swift
@@ -5,7 +5,7 @@ import SuggestionBasic
import SwiftUI
struct WidgetView: View {
- let store: StoreOf
+ let store: StoreOf
@State var isHovering: Bool = false
var onOpenChatClicked: () -> Void = {}
var onCustomCommandClicked: (CustomCommand) -> Void = { _ in }
@@ -41,7 +41,7 @@ struct WidgetView: View {
}
struct WidgetAnimatedCircle: View {
- let store: StoreOf
+ let store: StoreOf
@State var processingProgress: Double = 0
struct OverlayCircleState: Equatable {
@@ -133,7 +133,7 @@ struct WidgetContextMenu: View {
@AppStorage(\.suggestionFeatureEnabledProjectList) var suggestionFeatureEnabledProjectList
@AppStorage(\.suggestionFeatureDisabledLanguageList) var suggestionFeatureDisabledLanguageList
@AppStorage(\.customCommands) var customCommands
- let store: StoreOf
+ let store: StoreOf
@Dependency(\.xcodeInspector) var xcodeInspector
@@ -259,7 +259,7 @@ struct WidgetView_Preview: PreviewProvider {
isChatPanelDetached: false,
isChatOpen: false
),
- reducer: { CircularWidgetFeature() }
+ reducer: { CircularWidget() }
),
isHovering: false
)
@@ -273,7 +273,7 @@ struct WidgetView_Preview: PreviewProvider {
isChatPanelDetached: false,
isChatOpen: false
),
- reducer: { CircularWidgetFeature() }
+ reducer: { CircularWidget() }
),
isHovering: true
)
@@ -287,7 +287,7 @@ struct WidgetView_Preview: PreviewProvider {
isChatPanelDetached: false,
isChatOpen: false
),
- reducer: { CircularWidgetFeature() }
+ reducer: { CircularWidget() }
),
isHovering: false
)
@@ -301,7 +301,7 @@ struct WidgetView_Preview: PreviewProvider {
isChatPanelDetached: false,
isChatOpen: false
),
- reducer: { CircularWidgetFeature() }
+ reducer: { CircularWidget() }
),
isHovering: false
)
diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
index 2cda76b6..304f6d76 100644
--- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
+++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift
@@ -5,6 +5,7 @@ import Combine
import ComposableArchitecture
import Dependencies
import Foundation
+import SharedUIComponents
import SwiftUI
import XcodeInspector
@@ -17,7 +18,7 @@ actor WidgetWindowsController: NSObject {
var xcodeInspector: XcodeInspector { .shared }
let windows: WidgetWindows
- let store: StoreOf
+ let store: StoreOf
let chatTabPool: ChatTabPool
var currentApplicationProcessIdentifier: pid_t?
@@ -42,7 +43,7 @@ actor WidgetWindowsController: NSObject {
updateWindowStateTask?.cancel()
}
- init(store: StoreOf, chatTabPool: ChatTabPool) {
+ init(store: StoreOf, chatTabPool: ChatTabPool) {
self.store = store
self.chatTabPool = chatTabPool
windows = .init(store: store, chatTabPool: chatTabPool)
@@ -50,7 +51,7 @@ actor WidgetWindowsController: NSObject {
windows.controller = self
}
- @MainActor func send(_ action: WidgetFeature.Action) {
+ @MainActor func send(_ action: Widget.Action) {
store.send(action)
}
@@ -334,6 +335,7 @@ extension WidgetWindowsController {
return WidgetLocation(
widgetFrame: .zero,
tabFrame: .zero,
+ sharedPanelLocation: .init(frame: .zero, alignPanelTop: false),
defaultPanelLocation: .init(frame: .zero, alignPanelTop: false)
)
}
@@ -478,7 +480,7 @@ extension WidgetWindowsController {
animate: animated
)
windows.sharedPanelWindow.setFrame(
- widgetLocation.defaultPanelLocation.frame,
+ widgetLocation.sharedPanelLocation.frame,
display: false,
animate: animated
)
@@ -655,10 +657,9 @@ extension WidgetWindowsController: NSWindowDelegate {
// MARK: - Windows
public final class WidgetWindows {
- let store: StoreOf
+ let store: StoreOf
let chatTabPool: ChatTabPool
weak var controller: WidgetWindowsController?
- let cursorPositionTracker = CursorPositionTracker()
// you should make these window `.transient` so they never show up in the mission control.
@@ -728,7 +729,7 @@ public final class WidgetWindows {
state: \.sharedPanelState,
action: \.sharedPanel
)
- ).environment(cursorPositionTracker)
+ ).modifierFlagsMonitor()
)
it.setIsVisible(true)
it.canBecomeKeyChecker = { [store] in
@@ -761,7 +762,7 @@ public final class WidgetWindows {
state: \.suggestionPanelState,
action: \.suggestionPanel
)
- ).environment(cursorPositionTracker)
+ )
)
it.canBecomeKeyChecker = { false }
it.setIsVisible(true)
@@ -793,7 +794,7 @@ public final class WidgetWindows {
defer: false
)
it.isReleasedWhenClosed = false
- it.isOpaque = true
+ it.isOpaque = false
it.backgroundColor = .clear
it.level = widgetLevel(0)
it.hasShadow = false
@@ -804,13 +805,12 @@ public final class WidgetWindows {
))
)
it.setIsVisible(true)
- it.ignoresMouseEvents = true
it.canBecomeKeyChecker = { false }
return it
}()
init(
- store: StoreOf,
+ store: StoreOf,
chatTabPool: ChatTabPool
) {
self.store = store
diff --git a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
index 29a71e69..b12550f5 100644
--- a/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
+++ b/Core/Tests/ServiceTests/FilespaceSuggestionInvalidationTests.swift
@@ -8,6 +8,7 @@ import XCTest
class FilespaceSuggestionInvalidationTests: XCTestCase {
@WorkspaceActor
func prepare(
+ lines: [String],
suggestionText: String,
cursorPosition: CursorPosition,
range: CursorRange
@@ -23,18 +24,22 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
range: range
),
]
+ filespace.suggestionSourceSnapshot = .init(lines: lines, cursorPosition: cursorPosition)
return filespace
}
func test_text_typing_suggestion_should_be_valid() async throws {
+ let lines = ["\n", "hell\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hell\n", "\n"],
- cursorPosition: .init(line: 1, character: 4)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 4),
+ alwaysTrueIfCursorNotMoved: false // TODO: What
)
XCTAssertTrue(isValid)
let suggestion = filespace.presentingSuggestion
@@ -42,76 +47,116 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
}
func test_text_typing_suggestion_in_the_middle_should_be_valid() async throws {
+ let lines = ["\n", "hell man\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hell man\n", "\n"],
- cursorPosition: .init(line: 1, character: 4)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 4),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(isValid)
let suggestion = filespace.presentingSuggestion
XCTAssertNotNil(suggestion)
}
-
+
func test_text_typing_suggestion_with_emoji_in_the_middle_should_be_valid() async throws {
+ let lines = ["\n", "hell🎆🎆 man\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello🎆🎆 man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hell🎆🎆 man\n", "\n"],
- cursorPosition: .init(line: 1, character: 4)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 4),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(isValid)
let suggestion = filespace.presentingSuggestion
XCTAssertNotNil(suggestion)
}
-
+
func test_text_typing_suggestion_typed_emoji_in_the_middle_should_be_valid() async throws {
+ let lines = ["\n", "h🎆🎆o ma\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "h🎆🎆o man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "h🎆🎆o ma\n", "\n"],
- cursorPosition: .init(line: 1, character: 2)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 2),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(isValid)
let suggestion = filespace.presentingSuggestion
XCTAssertNotNil(suggestion)
}
-
+
func test_text_typing_suggestion_cutting_emoji_in_the_middle_should_be_valid() async throws {
// undefined behavior, must not crash
-
+
+ let lines = ["\n", "h🎆🎆o ma\n", "\n"]
+
let filespace = try await prepare(
+ lines: lines,
suggestionText: "h🎆🎆o man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "h🎆🎆o ma\n", "\n"],
- cursorPosition: .init(line: 1, character: 3)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 3),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(isValid)
let suggestion = filespace.presentingSuggestion
XCTAssertNotNil(suggestion)
}
+
+ func test_typing_not_according_to_suggestion_should_invalidate() async throws {
+ let lines = ["\n", "hello ma\n", "\n"]
+ let filespace = try await prepare(
+ lines: lines,
+ suggestionText: "hello man",
+ cursorPosition: .init(line: 1, character: 8),
+ range: .init(startPair: (1, 0), endPair: (1, 8))
+ )
+ let wasValid = await filespace.validateSuggestions(
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 8),
+ alwaysTrueIfCursorNotMoved: false
+ )
+ let isValid = await filespace.validateSuggestions(
+ lines: ["\n", "hello mat\n", "\n"],
+ cursorPosition: .init(line: 1, character: 9),
+ alwaysTrueIfCursorNotMoved: false
+ )
+ XCTAssertTrue(wasValid)
+ XCTAssertFalse(isValid)
+ let suggestion = filespace.presentingSuggestion
+ XCTAssertNil(suggestion)
+ }
func test_text_cursor_moved_to_another_line_should_invalidate() async throws {
+ let lines = ["\n", "hell\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hell\n", "\n"],
- cursorPosition: .init(line: 2, character: 0)
+ lines: lines,
+ cursorPosition: .init(line: 2, character: 0),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertFalse(isValid)
let suggestion = filespace.presentingSuggestion
@@ -119,14 +164,17 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
}
func test_text_cursor_is_invalid_should_invalidate() async throws {
+ let lines = ["\n", "hell\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
cursorPosition: .init(line: 100, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hell\n", "\n"],
- cursorPosition: .init(line: 100, character: 4)
+ lines: lines,
+ cursorPosition: .init(line: 100, character: 4),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertFalse(isValid)
let suggestion = filespace.presentingSuggestion
@@ -135,13 +183,15 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
func test_line_content_does_not_match_input_should_invalidate() async throws {
let filespace = try await prepare(
+ lines: ["\n", "hello\n", "\n"],
suggestionText: "hello man",
- cursorPosition: .init(line: 1, character: 0),
- range: .init(startPair: (1, 0), endPair: (1, 0))
+ cursorPosition: .init(line: 1, character: 5),
+ range: .init(startPair: (1, 0), endPair: (1, 5))
)
let isValid = await filespace.validateSuggestions(
lines: ["\n", "helo\n", "\n"],
- cursorPosition: .init(line: 1, character: 4)
+ cursorPosition: .init(line: 1, character: 4),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertFalse(isValid)
let suggestion = filespace.presentingSuggestion
@@ -150,13 +200,15 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
func test_line_content_does_not_match_input_should_invalidate_index_out_of_scope() async throws {
let filespace = try await prepare(
+ lines: ["\n", "hello\n", "\n"],
suggestionText: "hello man",
- cursorPosition: .init(line: 1, character: 0),
- range: .init(startPair: (1, 0), endPair: (1, 0))
+ cursorPosition: .init(line: 1, character: 5),
+ range: .init(startPair: (1, 0), endPair: (1, 5))
)
let isValid = await filespace.validateSuggestions(
lines: ["\n", "helo\n", "\n"],
- cursorPosition: .init(line: 1, character: 100)
+ cursorPosition: .init(line: 1, character: 100),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertFalse(isValid)
let suggestion = filespace.presentingSuggestion
@@ -164,38 +216,47 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
}
func test_finish_typing_the_whole_single_line_suggestion_should_invalidate() async throws {
+ let lines = ["\n", "hello ma\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
- cursorPosition: .init(line: 1, character: 0),
- range: .init(startPair: (1, 0), endPair: (1, 0))
+ cursorPosition: .init(line: 1, character: 8),
+ range: .init(startPair: (1, 0), endPair: (1, 8))
)
let wasValid = await filespace.validateSuggestions(
- lines: ["\n", "hello ma\n", "\n"],
- cursorPosition: .init(line: 1, character: 8)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 8),
+ alwaysTrueIfCursorNotMoved: false
)
let isValid = await filespace.validateSuggestions(
lines: ["\n", "hello man\n", "\n"],
- cursorPosition: .init(line: 1, character: 9)
+ cursorPosition: .init(line: 1, character: 9),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(wasValid)
XCTAssertFalse(isValid)
let suggestion = filespace.presentingSuggestion
XCTAssertNil(suggestion)
}
-
- func test_finish_typing_the_whole_single_line_suggestion_with_emoji_should_invalidate() async throws {
+
+ func test_finish_typing_the_whole_single_line_suggestion_with_emoji_should_invalidate(
+ ) async throws {
+ let lines = ["\n", "hello m🎆🎆a\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello m🎆🎆an",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let wasValid = await filespace.validateSuggestions(
- lines: ["\n", "hello m🎆🎆a\n", "\n"],
- cursorPosition: .init(line: 1, character: 12)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 12),
+ alwaysTrueIfCursorNotMoved: false
)
let isValid = await filespace.validateSuggestions(
lines: ["\n", "hello m🎆🎆an\n", "\n"],
- cursorPosition: .init(line: 1, character: 13)
+ cursorPosition: .init(line: 1, character: 13),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(wasValid)
XCTAssertFalse(isValid)
@@ -205,18 +266,22 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
func test_finish_typing_the_whole_single_line_suggestion_suggestion_is_incomplete_should_invalidate(
) async throws {
+ let lines = ["\n", "hello ma!!!!\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let wasValid = await filespace.validateSuggestions(
- lines: ["\n", "hello ma!!!!\n", "\n"],
- cursorPosition: .init(line: 1, character: 8)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 8),
+ alwaysTrueIfCursorNotMoved: false
)
let isValid = await filespace.validateSuggestions(
lines: ["\n", "hello man!!!!!\n", "\n"],
- cursorPosition: .init(line: 1, character: 9)
+ cursorPosition: .init(line: 1, character: 9),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(wasValid)
XCTAssertFalse(isValid)
@@ -225,29 +290,36 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
}
func test_finish_typing_the_whole_multiple_line_suggestion_should_be_valid() async throws {
+ let lines = ["\n", "hello man\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man\nhow are you?",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hello man\n", "\n"],
- cursorPosition: .init(line: 1, character: 9)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 9),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(isValid)
let suggestion = filespace.presentingSuggestion
XCTAssertNotNil(suggestion)
}
-
- func test_finish_typing_the_whole_multiple_line_suggestion_with_emoji_should_be_valid() async throws {
+
+ func test_finish_typing_the_whole_multiple_line_suggestion_with_emoji_should_be_valid(
+ ) async throws {
+ let lines = ["\n", "hello m🎆🎆an\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello m🎆🎆an\nhow are you?",
cursorPosition: .init(line: 1, character: 0),
range: .init(startPair: (1, 0), endPair: (1, 0))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hello m🎆🎆an\n", "\n"],
- cursorPosition: .init(line: 1, character: 13)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 13),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertTrue(isValid)
let suggestion = filespace.presentingSuggestion
@@ -256,18 +328,57 @@ class FilespaceSuggestionInvalidationTests: XCTestCase {
func test_undo_text_to_a_state_before_the_suggestion_was_generated_should_invalidate(
) async throws {
+ let lines = ["\n", "hell\n", "\n"]
let filespace = try await prepare(
+ lines: lines,
suggestionText: "hello man",
cursorPosition: .init(line: 1, character: 5), // generating man from hello
range: .init(startPair: (1, 0), endPair: (1, 5))
)
let isValid = await filespace.validateSuggestions(
- lines: ["\n", "hell\n", "\n"],
- cursorPosition: .init(line: 1, character: 4)
+ lines: lines,
+ cursorPosition: .init(line: 1, character: 4),
+ alwaysTrueIfCursorNotMoved: false
)
XCTAssertFalse(isValid)
let suggestion = filespace.presentingSuggestion
XCTAssertNil(suggestion)
}
+
+ func test_rewriting_the_current_line_by_removing_the_suffix_should_be_valid() async throws {
+ let lines = ["hello world !!!\n"]
+ let filespace = try await prepare(
+ lines: lines,
+ suggestionText: "hello world",
+ cursorPosition: .init(line: 0, character: 15),
+ range: .init(startPair: (0, 0), endPair: (0, 15))
+ )
+ let isValid = await filespace.validateSuggestions(
+ lines: lines,
+ cursorPosition: .init(line: 0, character: 15),
+ alwaysTrueIfCursorNotMoved: false
+ )
+ XCTAssertTrue(isValid)
+ let suggestion = filespace.presentingSuggestion
+ XCTAssertNotNil(suggestion)
+ }
+
+ func test_rewriting_the_current_line_should_be_valid() async throws {
+ let lines = ["hello everyone !!!\n"]
+ let filespace = try await prepare(
+ lines: lines,
+ suggestionText: "hello world !!!",
+ cursorPosition: .init(line: 0, character: 18),
+ range: .init(startPair: (0, 0), endPair: (0, 18))
+ )
+ let isValid = await filespace.validateSuggestions(
+ lines: lines,
+ cursorPosition: .init(line: 0, character: 18),
+ alwaysTrueIfCursorNotMoved: false
+ )
+ XCTAssertTrue(isValid)
+ let suggestion = filespace.presentingSuggestion
+ XCTAssertNotNil(suggestion)
+ }
}
diff --git a/EditorExtension/AcceptPromptToCodeCommand.swift b/EditorExtension/AcceptPromptToCodeCommand.swift
index 51bea4a4..7ac95c35 100644
--- a/EditorExtension/AcceptPromptToCodeCommand.swift
+++ b/EditorExtension/AcceptPromptToCodeCommand.swift
@@ -4,7 +4,7 @@ import SuggestionBasic
import XcodeKit
class AcceptPromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType {
- var name: String { "Accept Prompt to Code" }
+ var name: String { "Accept Modification" }
func perform(
with invocation: XCSourceEditorCommandInvocation,
diff --git a/EditorExtension/Helpers.swift b/EditorExtension/Helpers.swift
index 8851c279..beb49c66 100644
--- a/EditorExtension/Helpers.swift
+++ b/EditorExtension/Helpers.swift
@@ -1,5 +1,5 @@
-import SuggestionBasic
import Foundation
+import SuggestionBasic
import XcodeKit
import XPCShared
@@ -19,16 +19,21 @@ extension XCSourceEditorCommandInvocation {
}
func accept(_ updatedContent: UpdatedContent) {
- if let newSelection = updatedContent.newSelection {
+ if !updatedContent.newSelections.isEmpty {
mutateCompleteBuffer(
modifications: updatedContent.modifications,
restoringSelections: false
)
buffer.selections.removeAllObjects()
- buffer.selections.add(XCSourceTextRange(
- start: .init(line: newSelection.start.line, column: newSelection.start.character),
- end: .init(line: newSelection.end.line, column: newSelection.end.character)
- ))
+ for newSelection in updatedContent.newSelections {
+ buffer.selections.add(XCSourceTextRange(
+ start: .init(
+ line: newSelection.start.line,
+ column: newSelection.start.character
+ ),
+ end: .init(line: newSelection.end.line, column: newSelection.end.character)
+ ))
+ }
} else {
mutateCompleteBuffer(
modifications: updatedContent.modifications,
@@ -47,17 +52,17 @@ extension EditorContent {
uti: buffer.contentUTI,
cursorPosition: ((buffer.selections.lastObject as? XCSourceTextRange)?.end).map {
CursorPosition(line: $0.line, character: $0.column)
- } ?? CursorPosition(line: 0, character: 0),
+ } ?? CursorPosition(line: 0, character: 0),
cursorOffset: -1,
selections: buffer.selections.map {
let sl = ($0 as? XCSourceTextRange)?.start.line ?? 0
let sc = ($0 as? XCSourceTextRange)?.start.column ?? 0
let el = ($0 as? XCSourceTextRange)?.end.line ?? 0
let ec = ($0 as? XCSourceTextRange)?.end.column ?? 0
-
+
return Selection(
- start: CursorPosition( line: sl, character: sc ),
- end: CursorPosition( line: el, character: ec )
+ start: CursorPosition(line: sl, character: sc),
+ end: CursorPosition(line: el, character: ec)
)
},
tabSize: buffer.tabWidth,
@@ -96,3 +101,4 @@ extension Task where Failure == Error {
private struct TimeoutError: LocalizedError {
var errorDescription: String? = "Task timed out before completion"
}
+
diff --git a/EditorExtension/PromptToCodeCommand.swift b/EditorExtension/PromptToCodeCommand.swift
index 13e4f3be..e7086d57 100644
--- a/EditorExtension/PromptToCodeCommand.swift
+++ b/EditorExtension/PromptToCodeCommand.swift
@@ -4,7 +4,7 @@ import Foundation
import XcodeKit
class PromptToCodeCommand: NSObject, XCSourceEditorCommand, CommandType {
- var name: String { "Prompt to Code" }
+ var name: String { "Write or Modify Code" }
func perform(
with invocation: XCSourceEditorCommandInvocation,
diff --git a/ExtensionService/AppDelegate.swift b/ExtensionService/AppDelegate.swift
index c63b9a77..1d107672 100644
--- a/ExtensionService/AppDelegate.swift
+++ b/ExtensionService/AppDelegate.swift
@@ -18,6 +18,7 @@ let serviceIdentifier = bundleIdentifierBase + ".ExtensionService"
@main
class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate {
+ @MainActor
let service = Service.shared
var statusBarItem: NSStatusItem!
var xpcController: XPCController?
diff --git a/README.md b/README.md
index 893e3fe6..312e65ff 100644
--- a/README.md
+++ b/README.md
@@ -8,10 +8,10 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil
## Features
-- Code Suggestions (powered by GitHub Copilot and Codeium).
-- Chat (powered by OpenAI ChatGPT).
-- Prompt to Code (powered by OpenAI ChatGPT).
-- Custom Commands to extend Chat and Prompt to Code.
+- Code Suggestions
+- Chat
+- Modification
+- Custom Commands to extend Chat and Modification.
## Table of Contents
@@ -33,11 +33,9 @@ Copilot for Xcode is an Xcode Source Editor Extension that provides GitHub Copil
- [Limitations](#limitations)
- [License](#license)
-For frequently asked questions, check [FAQ](https://github.com/intitni/CopilotForXcode/wiki/Frequently-Asked-Questions).
-
For development instruction, check [Development.md](DEVELOPMENT.md).
-For more information, check the [wiki](https://github.com/intitni/CopilotForXcode/wiki)
+For more information, check the [Wiki Page](https://copilotforxcode.intii.com/wiki).
## Prerequisites
@@ -297,7 +295,7 @@ This feature is recommended when you need to update a specific piece of code. So
- Polishing and correcting grammar and spelling errors in the documentation.
- Translating a localizable strings file.
-#### Prompt to Code Scope
+#### Modification Scope
The chat panel allows for chat scope to temporarily control the context of the conversation for the latest message. To use a scope, simply prefix the message with `@scope`.
@@ -307,14 +305,14 @@ You can use shorthand to represent a scope, such as `@sense`, and enable multipl
#### Commands
-- Prompt to Code: Open a prompt to code window, where you can use natural language to write or edit selected code.
-- Accept Prompt to Code: Accept the result of prompt to code.
+- Write or Modify Code: Open a modification window, where you can use natural language to write or edit selected code.
+- Accept Modification: Accept the result of modification.
### Custom Commands
-You can create custom commands that run Chat and Prompt to Code with personalized prompts. These commands are easily accessible from both the Xcode menu bar and the context menu of the circular widget. There are 3 types of custom commands:
+You can create custom commands that run Chat and Modification with personalized prompts. These commands are easily accessible from both the Xcode menu bar and the context menu of the circular widget. There are 3 types of custom commands:
-- Prompt to Code: Run Prompt to Code with the selected code, and update or write the code using the given prompt, if provided. You can provide additional information through the extra system prompt field.
+- Modification: Run Modification with the selected code, and update or write the code using the given prompt, if provided. You can provide additional information through the extra system prompt field.
- Send Message: Open the chat window and immediately send a message, if provided. You can provide more information through the extra system prompt field.
- Custom Chat: Open the chat window and immediately send a message, if provided. You can overwrite the entire system prompt through the system prompt field.
- Single Round Dialog: Send a message to a temporary chat. Useful when you want to run a terminal command with `/run`.
diff --git a/TestPlan.xctestplan b/TestPlan.xctestplan
index 1d83d1c6..8806c7f2 100644
--- a/TestPlan.xctestplan
+++ b/TestPlan.xctestplan
@@ -29,13 +29,6 @@
"name" : "ServiceTests"
}
},
- {
- "target" : {
- "containerPath" : "container:Core",
- "identifier" : "SuggestionInjectorTests",
- "name" : "SuggestionInjectorTests"
- }
- },
{
"target" : {
"containerPath" : "container:Core",
@@ -154,6 +147,20 @@
"identifier" : "SuggestionBasicTests",
"name" : "SuggestionBasicTests"
}
+ },
+ {
+ "target" : {
+ "containerPath" : "container:Tool",
+ "identifier" : "SuggestionInjectorTests",
+ "name" : "SuggestionInjectorTests"
+ }
+ },
+ {
+ "target" : {
+ "containerPath" : "container:Tool",
+ "identifier" : "CodeDiffTests",
+ "name" : "CodeDiffTests"
+ }
}
],
"version" : 1
diff --git a/Tool/Package.swift b/Tool/Package.swift
index fdf323a0..06730e8b 100644
--- a/Tool/Package.swift
+++ b/Tool/Package.swift
@@ -20,7 +20,8 @@ let package = Package(
name: "ChatContextCollector",
targets: ["ChatContextCollector", "ActiveDocumentChatContextCollector"]
),
- .library(name: "SuggestionBasic", targets: ["SuggestionBasic"]),
+ .library(name: "SuggestionBasic", targets: ["SuggestionBasic", "SuggestionInjector"]),
+ .library(name: "PromptToCode", targets: ["PromptToCodeBasic", "PromptToCodeCustomization"]),
.library(name: "ASTParser", targets: ["ASTParser"]),
.library(name: "FocusedCodeFinder", targets: ["FocusedCodeFinder"]),
.library(name: "Toast", targets: ["Toast"]),
@@ -47,6 +48,8 @@ let package = Package(
.library(name: "DebounceFunction", targets: ["DebounceFunction"]),
.library(name: "AsyncPassthroughSubject", targets: ["AsyncPassthroughSubject"]),
.library(name: "CustomAsyncAlgorithms", targets: ["CustomAsyncAlgorithms"]),
+ .library(name: "CommandHandler", targets: ["CommandHandler"]),
+ .library(name: "CodeDiff", targets: ["CodeDiff"]),
],
dependencies: [
// A fork of https://github.com/aespinilla/Tiktoken to allow loading from local files.
@@ -62,7 +65,7 @@ let package = Package(
.package(url: "https://github.com/intitni/Highlightr", branch: "master"),
.package(
url: "https://github.com/pointfreeco/swift-composable-architecture",
- from: "1.10.4"
+ exact: "1.10.4"
),
.package(url: "https://github.com/apple/swift-syntax.git", exact: "509.0.2"),
.package(url: "https://github.com/GottaGetSwifty/CodableWrappers", from: "2.0.7"),
@@ -94,6 +97,9 @@ let package = Package(
.target(name: "ObjectiveCExceptionHandling"),
+ .target(name: "CodeDiff", dependencies: ["SuggestionBasic"]),
+ .testTarget(name: "CodeDiffTests", dependencies: ["CodeDiff"]),
+
.target(
name: "CustomAsyncAlgorithms",
dependencies: [
@@ -160,6 +166,15 @@ let package = Package(
]
),
+ .target(
+ name: "SuggestionInjector",
+ dependencies: ["SuggestionBasic"]
+ ),
+ .testTarget(
+ name: "SuggestionInjectorTests",
+ dependencies: ["SuggestionInjector"]
+ ),
+
.target(
name: "AIModel",
dependencies: [
@@ -171,7 +186,7 @@ let package = Package(
name: "SuggestionBasicTests",
dependencies: ["SuggestionBasic"]
),
-
+
.target(
name: "ChatBasic",
dependencies: [
@@ -182,6 +197,30 @@ let package = Package(
]
),
+ .target(
+ name: "PromptToCodeBasic",
+ dependencies: [
+ "SuggestionBasic",
+ .product(name: "CodableWrappers", package: "CodableWrappers"),
+ .product(
+ name: "ComposableArchitecture",
+ package: "swift-composable-architecture"
+ ),
+ ]
+ ),
+
+ .target(
+ name: "PromptToCodeCustomization",
+ dependencies: [
+ "PromptToCodeBasic",
+ "SuggestionBasic",
+ .product(
+ name: "ComposableArchitecture",
+ package: "swift-composable-architecture"
+ ),
+ ]
+ ),
+
.target(name: "AXExtension"),
.target(
@@ -232,6 +271,7 @@ let package = Package(
"Preferences",
"SuggestionBasic",
"DebounceFunction",
+ "CodeDiff",
.product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
]
),
@@ -264,6 +304,7 @@ let package = Package(
"SuggestionProvider",
"XPCShared",
"BuiltinExtension",
+ "SuggestionInjector",
]
),
@@ -294,6 +335,15 @@ let package = Package(
]
),
+ .target(
+ name: "CommandHandler",
+ dependencies: [
+ "XcodeInspector",
+ "Preferences",
+ .product(name: "ComposableArchitecture", package: "swift-composable-architecture"),
+ ]
+ ),
+
// MARK: - Services
.target(
@@ -303,6 +353,7 @@ let package = Package(
"ObjectiveCExceptionHandling",
"USearchIndex",
"ChatBasic",
+ .product(name: "JSONRPC", package: "JSONRPC"),
.product(name: "Parsing", package: "swift-parsing"),
.product(name: "SwiftSoup", package: "SwiftSoup"),
]
@@ -320,6 +371,16 @@ let package = Package(
.testTarget(name: "SuggestionProviderTests", dependencies: ["SuggestionProvider"]),
+ .target(
+ name: "RAGChatAgent",
+ dependencies: [
+ "ChatBasic",
+ "ChatContextCollector",
+ "OpenAIService",
+ "Preferences",
+ ]
+ ),
+
// MARK: - GitHub Copilot
.target(
@@ -334,6 +395,7 @@ let package = Package(
"BuiltinExtension",
"Toast",
"SuggestionProvider",
+ .product(name: "JSONRPC", package: "JSONRPC"),
.product(name: "LanguageServerProtocol", package: "LanguageServerProtocol"),
.product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
],
@@ -357,6 +419,8 @@ let package = Package(
"XcodeInspector",
"BuiltinExtension",
"ChatTab",
+ "SharedUIComponents",
+ .product(name: "JSONRPC", package: "JSONRPC"),
.product(name: "CopilotForXcodeKit", package: "CopilotForXcodeKit"),
]
),
@@ -417,6 +481,7 @@ let package = Package(
.target(
name: "ActiveDocumentChatContextCollector",
dependencies: [
+ "ASTParser",
"ChatContextCollector",
"OpenAIService",
"Preferences",
diff --git a/Tool/Sources/AIModel/ChatModel.swift b/Tool/Sources/AIModel/ChatModel.swift
index 3695343a..e0f0ef5c 100644
--- a/Tool/Sources/AIModel/ChatModel.swift
+++ b/Tool/Sources/AIModel/ChatModel.swift
@@ -38,9 +38,12 @@ public struct ChatModel: Codable, Equatable, Identifiable {
public struct OpenAIInfo: Codable, Equatable {
@FallbackDecoding
public var organizationID: String
+ @FallbackDecoding
+ public var projectID: String
- public init(organizationID: String = "") {
+ public init(organizationID: String = "", projectID: String = "") {
self.organizationID = organizationID
+ self.projectID = projectID
}
}
diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift
index 28a22227..008dec6e 100644
--- a/Tool/Sources/AXExtension/AXUIElement.swift
+++ b/Tool/Sources/AXExtension/AXUIElement.swift
@@ -21,7 +21,7 @@ public extension AXUIElement {
var value: String {
(try? copyValue(key: kAXValueAttribute)) ?? ""
}
-
+
var intValue: Int? {
(try? copyValue(key: kAXValueAttribute))
}
@@ -134,7 +134,7 @@ public extension AXUIElement {
var isFullScreen: Bool {
(try? copyValue(key: "AXFullScreen")) ?? false
}
-
+
var isFrontmost: Bool {
(try? copyValue(key: kAXFrontmostAttribute)) ?? false
}
@@ -191,6 +191,16 @@ public extension AXUIElement {
return nil
}
+ /// Get children that match the requirement
+ ///
+ /// - important: If the element has a lot of descendant nodes, it will heavily affect the
+ /// **performance of Xcode**. Please make use ``AXUIElement\traverse(_:)`` instead.
+ @available(
+ *,
+ deprecated,
+ renamed: "traverse(_:)",
+ message: "Please make use ``AXUIElement\traverse(_:)`` instead."
+ )
func children(where match: (AXUIElement) -> Bool) -> [AXUIElement] {
var all = [AXUIElement]()
for child in children {
@@ -233,6 +243,52 @@ public extension AXUIElement {
}
}
+public extension AXUIElement {
+ enum SearchNextStep {
+ case skipDescendants
+ case skipSiblings
+ case skipDescendantsAndSiblings
+ case continueSearching
+ case stopSearching
+ }
+
+ /// Traversing the element tree.
+ ///
+ /// - important: Traversing the element tree is resource consuming and will affect the
+ /// **performance of Xcode**. Please make sure to skip as much as possible.
+ ///
+ /// - todo: Make it not recursive.
+ func traverse(_ handle: (_ element: AXUIElement, _ level: Int) -> SearchNextStep) {
+ func _traverse(
+ element: AXUIElement,
+ level: Int,
+ handle: (AXUIElement, Int) -> SearchNextStep
+ ) -> SearchNextStep {
+ let nextStep = handle(element, level)
+ switch nextStep {
+ case .stopSearching: return .stopSearching
+ case .skipDescendants: return .continueSearching
+ case .skipDescendantsAndSiblings: return .skipSiblings
+ case .continueSearching, .skipSiblings:
+ for child in element.children {
+ switch _traverse(element: child, level: level + 1, handle: handle) {
+ case .skipSiblings, .skipDescendantsAndSiblings:
+ break
+ case .stopSearching:
+ return .stopSearching
+ case .continueSearching, .skipDescendants:
+ continue
+ }
+ }
+
+ return nextStep
+ }
+ }
+
+ _ = _traverse(element: self, level: 0, handle: handle)
+ }
+}
+
// MARK: - Helper
public extension AXUIElement {
diff --git a/Tool/Sources/AppActivator/AppActivator.swift b/Tool/Sources/AppActivator/AppActivator.swift
index b50f3bf4..cb5ec194 100644
--- a/Tool/Sources/AppActivator/AppActivator.swift
+++ b/Tool/Sources/AppActivator/AppActivator.swift
@@ -3,7 +3,7 @@ import Dependencies
import XcodeInspector
public extension NSWorkspace {
- static func activateThisApp(delay: TimeInterval = 0.3) {
+ static func activateThisApp(delay: TimeInterval = 0.10) {
Task { @MainActor in
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
diff --git a/Tool/Sources/ChatBasic/ChatAgent.swift b/Tool/Sources/ChatBasic/ChatAgent.swift
new file mode 100644
index 00000000..30be4caa
--- /dev/null
+++ b/Tool/Sources/ChatBasic/ChatAgent.swift
@@ -0,0 +1,38 @@
+import Foundation
+
+public enum ChatAgentResponse {
+ public enum Content {
+ case text(String)
+ case modification
+ }
+
+ /// Post the status of the current message.
+ case status(String)
+ /// Update the text message to the current message.
+ case content([Content])
+ /// Update the attachments of the current message.
+ case attachments([URL])
+ /// Update the references of the current message.
+ case references([ChatMessage.Reference])
+ /// End the current message. The next contents will be sent as a new message.
+ case startNewMessage
+}
+
+public struct ChatAgentRequest {
+ public var text: String
+ public var history: [ChatMessage]
+ public var extraContext: String
+
+ public init(text: String, history: [ChatMessage], extraContext: String) {
+ self.text = text
+ self.history = history
+ self.extraContext = extraContext
+ }
+}
+
+public protocol ChatAgent {
+ typealias Response = ChatAgentResponse
+ typealias Request = ChatAgentRequest
+ func send(_ request: Request) async -> AsyncThrowingStream
+}
+
diff --git a/Tool/Sources/ChatBasic/ChatGPTFunction.swift b/Tool/Sources/ChatBasic/ChatGPTFunction.swift
index 1131c247..0e2690da 100644
--- a/Tool/Sources/ChatBasic/ChatGPTFunction.swift
+++ b/Tool/Sources/ChatBasic/ChatGPTFunction.swift
@@ -43,14 +43,18 @@ public extension ChatGPTFunction {
argumentsJsonString: String,
reportProgress: @escaping ReportProgress
) async throws -> Result {
- do {
- let arguments = try JSONDecoder()
- .decode(Arguments.self, from: argumentsJsonString.data(using: .utf8) ?? Data())
- return try await call(arguments: arguments, reportProgress: reportProgress)
- } catch {
- await reportProgress("Error: Failed to decode arguments. \(error.localizedDescription)")
- throw error
- }
+ let arguments = try await {
+ do {
+ return try JSONDecoder()
+ .decode(Arguments.self, from: argumentsJsonString.data(using: .utf8) ?? Data())
+ } catch {
+ await reportProgress(
+ "Error: Failed to decode arguments. \(error.localizedDescription)"
+ )
+ throw error
+ }
+ }()
+ return try await call(arguments: arguments, reportProgress: reportProgress)
}
}
@@ -85,7 +89,7 @@ public extension ChatGPTArgumentsCollectingFunction {
assertionFailure("This function is only used to get a structured output from the bot.")
return ""
}
-
+
@available(
*,
deprecated,
diff --git a/Tool/Sources/ChatBasic/ChatMessage.swift b/Tool/Sources/ChatBasic/ChatMessage.swift
index 689fc0e5..a6df9be6 100644
--- a/Tool/Sources/ChatBasic/ChatMessage.swift
+++ b/Tool/Sources/ChatBasic/ChatMessage.swift
@@ -1,17 +1,22 @@
import CodableWrappers
import Foundation
+/// A chat message that can be sent or received.
public struct ChatMessage: Equatable, Codable {
public typealias ID = String
+ /// The role of a message.
public enum Role: String, Codable, Equatable {
case system
case user
case assistant
}
+ /// A function call that can be made by the bot.
public struct FunctionCall: Codable, Equatable {
+ /// The name of the function.
public var name: String
+ /// Arguments in the format of a JSON string.
public var arguments: String
public init(name: String, arguments: String) {
self.name = name
@@ -19,10 +24,14 @@ public struct ChatMessage: Equatable, Codable {
}
}
+ /// A tool call that can be made by the bot.
public struct ToolCall: Codable, Equatable, Identifiable {
public var id: String
+ /// The type of tool call.
public var type: String
+ /// The actual function call.
public var function: FunctionCall
+ /// The response of the function call.
public var response: ToolCallResponse
public init(
id: String,
@@ -37,8 +46,11 @@ public struct ChatMessage: Equatable, Codable {
}
}
+ /// The response of a tool call
public struct ToolCallResponse: Codable, Equatable {
+ /// The content of the response.
public var content: String
+ /// The summary of the response to display in UI.
public var summary: String?
public init(content: String, summary: String?) {
self.content = content
@@ -46,48 +58,50 @@ public struct ChatMessage: Equatable, Codable {
}
}
+ /// A reference to include in a chat message.
public struct Reference: Codable, Equatable {
- public enum Kind: String, Codable {
- case `class`
- case `struct`
- case `enum`
- case `actor`
- case `protocol`
- case `extension`
- case `case`
- case property
- case `typealias`
- case function
- case method
+ /// The kind of reference.
+ public enum Kind: Codable, Equatable {
+ public enum Symbol: String, Codable {
+ case `class`
+ case `struct`
+ case `enum`
+ case `actor`
+ case `protocol`
+ case `extension`
+ case `case`
+ case property
+ case `typealias`
+ case function
+ case method
+ }
+ /// Code symbol.
+ case symbol(Symbol, uri: String, startLine: Int?, endLine: Int?)
+ /// Some text.
case text
- case webpage
- case other
+ /// A webpage.
+ case webpage(uri: String)
+ /// A text file.
+ case textFile(uri: String)
+ /// Other kind of reference.
+ case other(kind: String)
}
+ /// The title of the reference.
public var title: String
- public var subTitle: String
- public var uri: String
+ /// The content of the reference.
public var content: String
- public var startLine: Int?
- public var endLine: Int?
+ /// The kind of the reference.
@FallbackDecoding
public var kind: Kind
public init(
title: String,
- subTitle: String,
content: String,
- uri: String,
- startLine: Int?,
- endLine: Int?,
kind: Kind
) {
self.title = title
- self.subTitle = subTitle
self.content = content
- self.uri = uri
- self.startLine = startLine
- self.endLine = endLine
self.kind = kind
}
}
@@ -116,6 +130,12 @@ public struct ChatMessage: Equatable, Codable {
/// The id of the message.
public var id: ID
+
+ /// The id of the sender of the message.
+ public var senderId: String?
+
+ /// The id of the message that this message is a response to.
+ public var responseTo: ID?
/// The number of tokens of this message.
public var tokensCount: Int?
@@ -134,6 +154,8 @@ public struct ChatMessage: Equatable, Codable {
public init(
id: String = UUID().uuidString,
+ senderId: String? = nil,
+ responseTo: String? = nil,
role: Role,
content: String?,
name: String? = nil,
@@ -143,6 +165,8 @@ public struct ChatMessage: Equatable, Codable {
references: [Reference] = []
) {
self.role = role
+ self.senderId = senderId
+ self.responseTo = responseTo
self.content = content
self.name = name
self.toolCalls = toolCalls
@@ -154,7 +178,7 @@ public struct ChatMessage: Equatable, Codable {
}
public struct ReferenceKindFallback: FallbackValueProvider {
- public static var defaultValue: ChatMessage.Reference.Kind { .other }
+ public static var defaultValue: ChatMessage.Reference.Kind { .other(kind: "Unknown") }
}
public struct ChatMessageRoleFallback: FallbackValueProvider {
diff --git a/Tool/Sources/CodeDiff/CodeDiff.swift b/Tool/Sources/CodeDiff/CodeDiff.swift
new file mode 100644
index 00000000..a2d64642
--- /dev/null
+++ b/Tool/Sources/CodeDiff/CodeDiff.swift
@@ -0,0 +1,494 @@
+import Foundation
+import SuggestionBasic
+
+public struct CodeDiff {
+ public init() {}
+
+ public typealias LineDiff = CollectionDifference
+
+ public struct SnippetDiff: Equatable {
+ public struct Change: Equatable {
+ public var offset: Int
+ public var element: String
+ }
+
+ public struct Line: Equatable {
+ public enum Diff: Equatable {
+ case unchanged
+ case mutated(changes: [Change])
+ }
+
+ public var text: String
+ public var diff: Diff = .unchanged
+ }
+
+ public struct Section: Equatable {
+ public var oldSnippet: [Line]
+ public var newSnippet: [Line]
+
+ public var isEmpty: Bool {
+ oldSnippet.isEmpty && newSnippet.isEmpty
+ }
+ }
+
+ public var sections: [Section]
+
+ public func line(at index: Int, in keyPath: KeyPath) -> Line? {
+ var previousSectionEnd = 0
+ for section in sections {
+ let lines = section[keyPath: keyPath]
+ let index = index - previousSectionEnd
+ if index < lines.endIndex {
+ return lines[index]
+ }
+ previousSectionEnd += lines.endIndex
+ }
+ return nil
+ }
+ }
+
+ public func diff(text: String, from oldText: String) -> LineDiff {
+ typealias Change = LineDiff.Change
+ let diffByCharacter = text.difference(from: oldText)
+ var result = [Change]()
+
+ var current: Change?
+ for item in diffByCharacter {
+ if let this = current {
+ switch (this, item) {
+ case let (.insert(offset, element, associatedWith), .insert(offsetB, elementB, _))
+ where offset + element.count == offsetB:
+ current = .insert(
+ offset: offset,
+ element: element + String(elementB),
+ associatedWith: associatedWith
+ )
+ continue
+ case let (.remove(offset, element, associatedWith), .remove(offsetB, elementB, _))
+ where offset - 1 == offsetB:
+ current = .remove(
+ offset: offsetB,
+ element: String(elementB) + element,
+ associatedWith: associatedWith
+ )
+ continue
+ default:
+ result.append(this)
+ }
+ }
+
+ current = switch item {
+ case let .insert(offset, element, associatedWith):
+ .insert(offset: offset, element: String(element), associatedWith: associatedWith)
+ case let .remove(offset, element, associatedWith):
+ .remove(offset: offset, element: String(element), associatedWith: associatedWith)
+ }
+ }
+
+ if let current {
+ result.append(current)
+ }
+
+ return .init(result) ?? [].difference(from: [])
+ }
+
+ public func diff(snippet: String, from oldSnippet: String) -> SnippetDiff {
+ let newLines = snippet.splitByNewLine(omittingEmptySubsequences: false)
+ let oldLines = oldSnippet.splitByNewLine(omittingEmptySubsequences: false)
+ let diffByLine = newLines.difference(from: oldLines)
+
+ let (insertions, removals) = generateDiffSections(
+ oldLines: oldLines,
+ newLines: newLines,
+ diffByLine: diffByLine
+ )
+
+ var oldLineIndex = 0
+ var newLineIndex = 0
+ var sectionIndex = 0
+ var result = SnippetDiff(sections: [])
+
+ while oldLineIndex < oldLines.endIndex || newLineIndex < newLines.endIndex {
+ let removalSection = removals[safe: sectionIndex]
+ let insertionSection = insertions[safe: sectionIndex]
+
+ // handle lines before sections
+ var beforeSection = SnippetDiff.Section(oldSnippet: [], newSnippet: [])
+
+ while oldLineIndex < (removalSection?.offset ?? oldLines.endIndex) {
+ if oldLineIndex < oldLines.endIndex {
+ beforeSection.oldSnippet.append(.init(
+ text: String(oldLines[oldLineIndex]),
+ diff: .unchanged
+ ))
+ }
+ oldLineIndex += 1
+ }
+ while newLineIndex < (insertionSection?.offset ?? newLines.endIndex) {
+ if newLineIndex < newLines.endIndex {
+ beforeSection.newSnippet.append(.init(
+ text: String(newLines[newLineIndex]),
+ diff: .unchanged
+ ))
+ }
+ newLineIndex += 1
+ }
+
+ if !beforeSection.isEmpty {
+ result.sections.append(beforeSection)
+ }
+
+ // handle lines inside sections
+
+ var insideSection = SnippetDiff.Section(oldSnippet: [], newSnippet: [])
+
+ for i in 0.. Bool {
+ if end + 1 != offset { return false }
+ end = offset
+ lines.append(String(element))
+ return true
+ }
+ }
+
+ func generateDiffSections(
+ oldLines: [Substring],
+ newLines: [Substring],
+ diffByLine: CollectionDifference
+ ) -> (insertionSections: [DiffSection], removalSections: [DiffSection]) {
+ let insertionDiffs = diffByLine.insertions
+ let removalDiffs = diffByLine.removals
+ var insertions = [DiffSection]()
+ var removals = [DiffSection]()
+ var insertionIndex = 0
+ var removalIndex = 0
+ var insertionUnchangedGap = 0
+ var removalUnchangedGap = 0
+
+ while insertionIndex < insertionDiffs.endIndex || removalIndex < removalDiffs.endIndex {
+ let insertion = insertionDiffs[safe: insertionIndex]
+ let removal = removalDiffs[safe: removalIndex]
+
+ append(
+ into: &insertions,
+ change: insertion,
+ index: &insertionIndex,
+ unchangedGap: &insertionUnchangedGap
+ ) { change in
+ guard case let .insert(offset, element, _) = change else { return nil }
+ return (offset, element)
+ }
+
+ append(
+ into: &removals,
+ change: removal,
+ index: &removalIndex,
+ unchangedGap: &removalUnchangedGap
+ ) { change in
+ guard case let .remove(offset, element, _) = change else { return nil }
+ return (offset, element)
+ }
+
+ if insertionUnchangedGap > removalUnchangedGap {
+ // insert empty sections to insertions
+ if removalUnchangedGap > 0 {
+ let count = insertionUnchangedGap - removalUnchangedGap
+ let index = max(insertions.endIndex - 1, 0)
+ let offset = (insertions.last?.offset ?? 0) - count
+ insertions.insert(
+ .init(offset: offset, end: offset, lines: []),
+ at: index
+ )
+ insertionUnchangedGap -= removalUnchangedGap
+ removalUnchangedGap = 0
+ } else if removal == nil {
+ removalUnchangedGap = 0
+ insertionUnchangedGap = 0
+ }
+ } else if removalUnchangedGap > insertionUnchangedGap {
+ // insert empty sections to removals
+ if insertionUnchangedGap > 0 {
+ let count = removalUnchangedGap - insertionUnchangedGap
+ let index = max(removals.endIndex - 1, 0)
+ let offset = (removals.last?.offset ?? 0) - count
+ removals.insert(
+ .init(offset: offset, end: offset, lines: []),
+ at: index
+ )
+ removalUnchangedGap -= insertionUnchangedGap
+ insertionUnchangedGap = 0
+ } else {
+ removalUnchangedGap = 0
+ insertionUnchangedGap = 0
+ }
+ } else {
+ removalUnchangedGap = 0
+ insertionUnchangedGap = 0
+ }
+ }
+
+ return (insertions, removals)
+ }
+
+ func append(
+ into sections: inout [DiffSection],
+ change: CollectionDifference.Change?,
+ index: inout Int,
+ unchangedGap: inout Int,
+ extract: (CollectionDifference.Change) -> (offset: Int, line: Substring)?
+ ) {
+ guard let change, let (offset, element) = extract(change) else { return }
+ if unchangedGap == 0 {
+ if !sections.isEmpty {
+ let lastIndex = sections.endIndex - 1
+ if !sections[lastIndex]
+ .appendIfPossible(offset: offset, element: element)
+ {
+ unchangedGap = offset - sections[lastIndex].end - 1
+ sections.append(.init(
+ offset: offset,
+ end: offset,
+ lines: [String(element)]
+ ))
+ }
+ } else {
+ sections.append(.init(
+ offset: offset,
+ end: offset,
+ lines: [String(element)]
+ ))
+ unchangedGap = offset
+ }
+ index += 1
+ }
+ }
+}
+
+extension Array {
+ subscript(safe index: Int) -> Element? {
+ guard index >= 0, index < count else { return nil }
+ return self[index]
+ }
+
+ subscript(safe index: Int, fallback fallback: Element) -> Element {
+ guard index >= 0, index < count else { return fallback }
+ return self[index]
+ }
+}
+
+#if DEBUG
+
+import SwiftUI
+
+struct SnippetDiffPreview: View {
+ let originalCode: String
+ let newCode: String
+
+ var body: some View {
+ HStack(alignment: .top) {
+ let (original, new) = generateTexts()
+ block(original)
+ Divider()
+ block(new)
+ }
+ .padding()
+ .font(.body.monospaced())
+ }
+
+ @ViewBuilder
+ func block(_ code: [AttributedString]) -> some View {
+ VStack(alignment: .leading) {
+ if !code.isEmpty {
+ ForEach(0.. (original: [AttributedString], new: [AttributedString]) {
+ let diff = CodeDiff().diff(snippet: newCode, from: originalCode)
+
+ let new = diff.sections.flatMap {
+ $0.newSnippet.map {
+ let text = $0.text.trimmingCharacters(in: .newlines)
+ let string = NSMutableAttributedString(string: text)
+ if case let .mutated(changes) = $0.diff {
+ string.addAttribute(
+ .backgroundColor,
+ value: NSColor.green.withAlphaComponent(0.1),
+ range: NSRange(location: 0, length: text.count)
+ )
+
+ for diffItem in changes {
+ string.addAttribute(
+ .backgroundColor,
+ value: NSColor.green.withAlphaComponent(0.5),
+ range: NSRange(
+ location: diffItem.offset,
+ length: min(
+ text.count - diffItem.offset,
+ diffItem.element.count
+ )
+ )
+ )
+ }
+ }
+ return string
+ }
+ }
+
+ let original = diff.sections.flatMap {
+ $0.oldSnippet.map {
+ let text = $0.text.trimmingCharacters(in: .newlines)
+ let string = NSMutableAttributedString(string: text)
+ if case let .mutated(changes) = $0.diff {
+ string.addAttribute(
+ .backgroundColor,
+ value: NSColor.red.withAlphaComponent(0.1),
+ range: NSRange(location: 0, length: text.count)
+ )
+
+ for diffItem in changes {
+ string.addAttribute(
+ .backgroundColor,
+ value: NSColor.red.withAlphaComponent(0.5),
+ range: NSRange(
+ location: diffItem.offset,
+ length: min(text.count - diffItem.offset, diffItem.element.count)
+ )
+ )
+ }
+ }
+
+ return string
+ }
+ }
+
+ return (original.map(AttributedString.init), new.map(AttributedString.init))
+ }
+}
+
+struct LineDiffPreview: View {
+ let originalCode: String
+ let newCode: String
+
+ var body: some View {
+ VStack(alignment: .leading) {
+ let (original, new) = generateTexts()
+ Text(original)
+ Divider()
+ Text(new)
+ }
+ .padding()
+ .font(.body.monospaced())
+ }
+
+ func generateTexts() -> (original: AttributedString, new: AttributedString) {
+ let diff = CodeDiff().diff(text: newCode, from: originalCode)
+ let original = NSMutableAttributedString(string: originalCode)
+ let new = NSMutableAttributedString(string: newCode)
+
+ for item in diff {
+ switch item {
+ case let .insert(offset, element, _):
+ new.addAttribute(
+ .backgroundColor,
+ value: NSColor.green.withAlphaComponent(0.5),
+ range: NSRange(location: offset, length: element.count)
+ )
+ case let .remove(offset, element, _):
+ original.addAttribute(
+ .backgroundColor,
+ value: NSColor.red.withAlphaComponent(0.5),
+ range: NSRange(location: offset, length: element.count)
+ )
+ }
+ }
+
+ return (.init(original), .init(new))
+ }
+}
+
+#Preview("Line Diff") {
+ let originalCode = """
+ let foo = Foo() // yes
+ """
+ let newCode = """
+ var foo = Bar()
+ """
+
+ return LineDiffPreview(originalCode: originalCode, newCode: newCode)
+}
+
+#Preview("Snippet Diff") {
+ let originalCode = """
+ let foo = Foo()
+ print(foo)
+ // do something
+ foo.foo()
+ func zoo() {}
+ """
+ let newCode = """
+ var foo = Bar()
+ // do something
+ foo.bar()
+ func zoo() {
+ print("zoo")
+ }
+ """
+
+ return SnippetDiffPreview(originalCode: originalCode, newCode: newCode)
+}
+
+#endif
+
diff --git a/Tool/Sources/CommandHandler/CommandHandler.swift b/Tool/Sources/CommandHandler/CommandHandler.swift
new file mode 100644
index 00000000..afba1e96
--- /dev/null
+++ b/Tool/Sources/CommandHandler/CommandHandler.swift
@@ -0,0 +1,124 @@
+import Dependencies
+import Foundation
+import Preferences
+import SuggestionBasic
+import Toast
+import XcodeInspector
+
+/// Provides an interface to handle commands.
+public protocol CommandHandler {
+ // MARK: Suggestion
+
+ func presentSuggestions(_ suggestions: [CodeSuggestion]) async
+ func presentPreviousSuggestion() async
+ func presentNextSuggestion() async
+ func rejectSuggestions() async
+ func acceptSuggestion() async
+ func dismissSuggestion() async
+ func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async
+
+ // MARK: Chat
+
+ func openChat(forceDetach: Bool, activateThisApp: Bool)
+ func sendChatMessage(_ message: String) async
+
+ // MARK: Prompt to Code
+
+ func acceptPromptToCode() async
+
+ // MARK: Custom Command
+
+ func handleCustomCommand(_ command: CustomCommand) async
+
+ // MARK: Toast
+
+ func toast(_ string: String, as type: ToastType)
+}
+
+public struct CommandHandlerDependencyKey: DependencyKey {
+ public static var liveValue: CommandHandler = UniversalCommandHandler.shared
+ public static var testValue: CommandHandler = NoopCommandHandler()
+}
+
+public extension DependencyValues {
+ /// In production, you need to override the command handler globally by setting
+ /// ``UniversalCommandHandler.shared.commandHandler``.
+ ///
+ /// In tests, you can use ``withDependency`` to mock it.
+ var commandHandler: CommandHandler {
+ get { self[CommandHandlerDependencyKey.self] }
+ set { self[CommandHandlerDependencyKey.self] = newValue }
+ }
+}
+
+public final class UniversalCommandHandler: CommandHandler {
+ public static let shared: UniversalCommandHandler = UniversalCommandHandler()
+
+ public var commandHandler: CommandHandler = NoopCommandHandler()
+
+ private init() {}
+
+ public func presentSuggestions(_ suggestions: [SuggestionBasic.CodeSuggestion]) async {
+ await commandHandler.presentSuggestions(suggestions)
+ }
+
+ public func presentPreviousSuggestion() async {
+ await commandHandler.presentPreviousSuggestion()
+ }
+
+ public func presentNextSuggestion() async {
+ await commandHandler.presentNextSuggestion()
+ }
+
+ public func rejectSuggestions() async {
+ await commandHandler.rejectSuggestions()
+ }
+
+ public func acceptSuggestion() async {
+ await commandHandler.acceptSuggestion()
+ }
+
+ public func dismissSuggestion() async {
+ await commandHandler.dismissSuggestion()
+ }
+
+ public func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async {
+ await commandHandler.generateRealtimeSuggestions(sourceEditor: sourceEditor)
+ }
+
+ public func openChat(forceDetach: Bool, activateThisApp: Bool) {
+ commandHandler.openChat(forceDetach: forceDetach, activateThisApp: activateThisApp)
+ }
+
+ public func sendChatMessage(_ message: String) async {
+ await commandHandler.sendChatMessage(message)
+ }
+
+ public func acceptPromptToCode() async {
+ await commandHandler.acceptPromptToCode()
+ }
+
+ public func handleCustomCommand(_ command: CustomCommand) async {
+ await commandHandler.handleCustomCommand(command)
+ }
+
+ public func toast(_ string: String, as type: ToastType) {
+ commandHandler.toast(string, as: type)
+ }
+}
+
+struct NoopCommandHandler: CommandHandler {
+ func presentSuggestions(_: [CodeSuggestion]) async {}
+ func presentPreviousSuggestion() async {}
+ func presentNextSuggestion() async {}
+ func rejectSuggestions() async {}
+ func acceptSuggestion() async {}
+ func dismissSuggestion() async {}
+ func generateRealtimeSuggestions(sourceEditor: SourceEditor?) async {}
+ func openChat(forceDetach: Bool, activateThisApp: Bool) {}
+ func sendChatMessage(_: String) async {}
+ func acceptPromptToCode() async {}
+ func handleCustomCommand(_: CustomCommand) async {}
+ func toast(_: String, as: ToastType) {}
+}
+
diff --git a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift
index 7778b0e5..ed11d4b2 100644
--- a/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift
+++ b/Tool/Sources/GitHubCopilotService/LanguageServer/GitHubCopilotInstallationManager.swift
@@ -6,12 +6,12 @@ public struct GitHubCopilotInstallationManager {
public private(set) static var isInstalling = false
static var downloadURL: URL {
- let commitHash = "0668308e68b0ac28b332b204b469fbe04601536a"
+ let commitHash = "782461159655b259cff10ecff05efa761e3d4764"
let link = "https://github.com/github/copilot.vim/archive/\(commitHash).zip"
return URL(string: link)!
}
- static let latestSupportedVersion = "1.37.0"
+ static let latestSupportedVersion = "1.40.0"
static let minimumSupportedVersion = "1.32.0"
public init() {}
diff --git a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift
index 83bd827a..2023e3c9 100644
--- a/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift
+++ b/Tool/Sources/LangChain/ChatModel/OpenAIChat.swift
@@ -26,7 +26,7 @@ public struct OpenAIChat: ChatModel {
) async throws -> ChatMessage {
let memory = memory ?? EmptyChatGPTMemory()
- let service = ChatGPTService(
+ let service = LegacyChatGPTService(
memory: memory,
configuration: configuration,
functionProvider: functionProvider
diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift
new file mode 100644
index 00000000..f114b32d
--- /dev/null
+++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIBuilder.swift
@@ -0,0 +1,123 @@
+import AIModel
+import ChatBasic
+import Dependencies
+import Foundation
+
+protocol ChatCompletionsAPIBuilder {
+ func buildStreamAPI(
+ model: ChatModel,
+ endpoint: URL,
+ apiKey: String,
+ requestBody: ChatCompletionsRequestBody
+ ) -> any ChatCompletionsStreamAPI
+
+ func buildNonStreamAPI(
+ model: ChatModel,
+ endpoint: URL,
+ apiKey: String,
+ requestBody: ChatCompletionsRequestBody
+ ) -> any ChatCompletionsAPI
+}
+
+struct DefaultChatCompletionsAPIBuilder: ChatCompletionsAPIBuilder {
+ func buildStreamAPI(
+ model: ChatModel,
+ endpoint: URL,
+ apiKey: String,
+ requestBody: ChatCompletionsRequestBody
+ ) -> any ChatCompletionsStreamAPI {
+ if model.id == "com.github.copilot" {
+ return BuiltinExtensionChatCompletionsService(
+ extensionIdentifier: model.id,
+ requestBody: requestBody
+ )
+ }
+
+ switch model.format {
+ case .googleAI:
+ return GoogleAIChatCompletionsService(
+ apiKey: apiKey,
+ model: model,
+ requestBody: requestBody,
+ baseURL: endpoint.absoluteString
+ )
+ case .openAI, .openAICompatible, .azureOpenAI:
+ return OpenAIChatCompletionsService(
+ apiKey: apiKey,
+ model: model,
+ endpoint: endpoint,
+ requestBody: requestBody
+ )
+ case .ollama:
+ return OllamaChatCompletionsService(
+ apiKey: apiKey,
+ model: model,
+ endpoint: endpoint,
+ requestBody: requestBody
+ )
+ case .claude:
+ return ClaudeChatCompletionsService(
+ apiKey: apiKey,
+ model: model,
+ endpoint: endpoint,
+ requestBody: requestBody
+ )
+ }
+ }
+
+ func buildNonStreamAPI(
+ model: ChatModel,
+ endpoint: URL,
+ apiKey: String,
+ requestBody: ChatCompletionsRequestBody
+ ) -> any ChatCompletionsAPI {
+ if model.id == "com.github.copilot" {
+ return BuiltinExtensionChatCompletionsService(
+ extensionIdentifier: model.id,
+ requestBody: requestBody
+ )
+ }
+
+ switch model.format {
+ case .googleAI:
+ return GoogleAIChatCompletionsService(
+ apiKey: apiKey,
+ model: model,
+ requestBody: requestBody,
+ baseURL: endpoint.absoluteString
+ )
+ case .openAI, .openAICompatible, .azureOpenAI:
+ return OpenAIChatCompletionsService(
+ apiKey: apiKey,
+ model: model,
+ endpoint: endpoint,
+ requestBody: requestBody
+ )
+ case .ollama:
+ return OllamaChatCompletionsService(
+ apiKey: apiKey,
+ model: model,
+ endpoint: endpoint,
+ requestBody: requestBody
+ )
+ case .claude:
+ return ClaudeChatCompletionsService(
+ apiKey: apiKey,
+ model: model,
+ endpoint: endpoint,
+ requestBody: requestBody
+ )
+ }
+ }
+}
+
+struct ChatCompletionsAPIBuilderDependencyKey: DependencyKey {
+ static var liveValue: ChatCompletionsAPIBuilder = DefaultChatCompletionsAPIBuilder()
+}
+
+extension DependencyValues {
+ var chatCompletionsAPIBuilder: ChatCompletionsAPIBuilder {
+ get { self[ChatCompletionsAPIBuilderDependencyKey.self] }
+ set { self[ChatCompletionsAPIBuilderDependencyKey.self] = newValue }
+ }
+}
diff --git a/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift
index 2770b6e2..e81609f9 100644
--- a/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift
+++ b/Tool/Sources/OpenAIService/APIs/GoogleAIChatCompletionsService.swift
@@ -7,20 +7,17 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA
let apiKey: String
let model: ChatModel
var requestBody: ChatCompletionsRequestBody
- let prompt: ChatGPTPrompt
let baseURL: String
init(
apiKey: String,
model: ChatModel,
requestBody: ChatCompletionsRequestBody,
- prompt: ChatGPTPrompt,
baseURL: String
) {
self.apiKey = apiKey
self.model = model
self.requestBody = requestBody
- self.prompt = prompt
self.baseURL = baseURL
}
@@ -36,9 +33,7 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA
? .init()
: .init(apiVersion: model.info.googleGenerativeAIInfo.apiVersion)
)
- let history = prompt.googleAICompatible.history.map { message in
- ModelContent(message)
- }
+ let history = Self.convertMessages(requestBody.messages)
do {
let response = try await aiModel.generateContent(history)
@@ -86,7 +81,7 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA
? .init()
: .init(apiVersion: model.info.googleGenerativeAIInfo.apiVersion)
)
- let history = prompt.googleAICompatible.history.map { message in
+ let history = requestBody.messages.map { message in
ModelContent(message)
}
@@ -135,15 +130,15 @@ actor GoogleAIChatCompletionsService: ChatCompletionsAPI, ChatCompletionsStreamA
return stream
}
-}
-extension ChatGPTPrompt {
- var googleAICompatible: ChatGPTPrompt {
- var history = self.history
- var reformattedHistory = [ChatMessage]()
+ static func convertMessages(
+ _ messages: [ChatCompletionsRequestBody.Message]
+ ) -> [ModelContent] {
+ var history = messages
+ var reformattedHistory = [ChatCompletionsRequestBody.Message]()
// We don't want to combine the new user message with others.
- let newUserMessage: ChatMessage? = if history.last?.role == .user {
+ let newUserMessage: ChatCompletionsRequestBody.Message? = if history.last?.role == .user {
history.removeLast()
} else {
nil
@@ -154,7 +149,6 @@ extension ChatGPTPrompt {
guard lastIndex >= 0 else { // first message
if message.role == .system {
reformattedHistory.append(.init(
- id: message.id,
role: .user,
content: ModelContent.convertContent(of: message)
))
@@ -174,8 +168,7 @@ extension ChatGPTPrompt {
if ModelContent.convertRole(lastMessage.role) == ModelContent
.convertRole(message.role)
{
- let newMessage = ChatMessage(
- id: message.id,
+ let newMessage = ChatCompletionsRequestBody.Message(
role: message.role == .assistant ? .assistant : .user,
content: """
\(ModelContent.convertContent(of: lastMessage))
@@ -197,7 +190,7 @@ extension ChatGPTPrompt {
.convertRole(newUserMessage.role)
{
// Add dummy message
- let dummyMessage = ChatMessage(
+ let dummyMessage = ChatCompletionsRequestBody.Message(
role: .assistant,
content: "OK"
)
@@ -206,47 +199,47 @@ extension ChatGPTPrompt {
reformattedHistory.append(newUserMessage)
}
- return .init(
- history: reformattedHistory,
- references: references,
- remainingTokenCount: remainingTokenCount
- )
+ return reformattedHistory.map(ModelContent.init)
}
}
extension ModelContent {
- static func convertRole(_ role: ChatMessage.Role) -> String {
+ static func convertRole(_ role: ChatCompletionsRequestBody.Message.Role) -> String {
switch role {
- case .user, .system:
+ case .user, .system, .tool:
return "user"
case .assistant:
return "model"
}
}
- static func convertContent(of message: ChatMessage) -> String {
+ static func convertContent(of message: ChatCompletionsRequestBody.Message) -> String {
switch message.role {
case .system:
- return "System Prompt:\n\(message.content ?? " ")"
+ return "System Prompt:\n\(message.content)"
case .user:
- return message.content ?? " "
+ return message.content
+ case .tool:
+ return """
+ Result of function ID: \(message.toolCallId ?? "")
+ \(message.content)
+ """
case .assistant:
if let toolCalls = message.toolCalls {
return toolCalls.map { call in
- let response = call.response
return """
+ Function ID: \(call.id)
Call function: \(call.function.name)
- Arguments: \(call.function.arguments)
- Result: \(response.content)
+ Arguments: \(call.function.arguments ?? "{}")
"""
}.joined(separator: "\n")
} else {
- return message.content ?? " "
+ return message.content
}
}
}
- init(_ message: ChatMessage) {
+ init(_ message: ChatCompletionsRequestBody.Message) {
let role = Self.convertRole(message.role)
let parts = [ModelContent.Part.text(Self.convertContent(of: message))]
self = .init(role: role, parts: parts)
diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift
index 7a6672cb..618c7720 100644
--- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift
+++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift
@@ -253,6 +253,9 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI
}
guard let data = text.data(using: .utf8)
else { throw ChatGPTServiceError.responseInvalid }
+ if response.statusCode == 403 {
+ throw ChatGPTServiceError.unauthorized(text)
+ }
let decoder = JSONDecoder()
let error = try? decoder.decode(CompletionAPIError.self, from: data)
throw error ?? ChatGPTServiceError.responseInvalid
@@ -347,6 +350,14 @@ actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatCompletionsAPI
forHTTPHeaderField: "OpenAI-Organization"
)
}
+
+ if !model.info.openAIInfo.projectID.isEmpty {
+ request.setValue(
+ model.info.openAIInfo.projectID,
+ forHTTPHeaderField: "OpenAI-Project"
+ )
+ }
+
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
case .openAICompatible:
request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "Authorization")
diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift
index 669fae10..a35d8874 100644
--- a/Tool/Sources/OpenAIService/ChatGPTService.swift
+++ b/Tool/Sources/OpenAIService/ChatGPTService.swift
@@ -6,18 +6,12 @@ import Foundation
import IdentifiedCollections
import Preferences
-public protocol ChatGPTServiceType {
- var memory: ChatGPTMemory { get set }
- var configuration: ChatGPTConfiguration { get set }
- func send(content: String, summary: String?) async throws -> AsyncThrowingStream
- func stopReceivingMessage() async
-}
-
public enum ChatGPTServiceError: Error, LocalizedError {
case chatModelNotAvailable
case embeddingModelNotAvailable
case endpointIncorrect
case responseInvalid
+ case unauthorized(String)
case otherError(String)
public var errorDescription: String? {
@@ -30,6 +24,8 @@ public enum ChatGPTServiceError: Error, LocalizedError {
return "ChatGPT endpoint is incorrect"
case .responseInvalid:
return "Response is invalid"
+ case let .unauthorized(reason):
+ return "Unauthorized: \(reason)"
case let .otherError(content):
return content
}
@@ -66,199 +62,145 @@ public struct ChatGPTError: Error, Codable, LocalizedError {
}
}
-typealias ChatCompletionsStreamAPIBuilder = (
- String,
- ChatModel,
- URL,
- ChatCompletionsRequestBody,
- ChatGPTPrompt
-) -> any ChatCompletionsStreamAPI
-
-typealias ChatCompletionsAPIBuilder = (
- String,
- ChatModel,
- URL,
- ChatCompletionsRequestBody,
- ChatGPTPrompt
-) -> any ChatCompletionsAPI
-
-public class ChatGPTService: ChatGPTServiceType {
- public var memory: ChatGPTMemory
- public var configuration: ChatGPTConfiguration
- public var functionProvider: ChatGPTFunctionProvider
+public enum ChatGPTResponse: Equatable {
+ case status(String)
+ case partialText(String)
+ case toolCalls([ChatMessage.ToolCall])
+}
- var runningTask: Task?
- var buildCompletionStreamAPI: ChatCompletionsStreamAPIBuilder = {
- apiKey, model, endpoint, requestBody, prompt in
+public typealias ChatGPTResponseStream = AsyncThrowingStream
- if model.id == "com.github.copilot" {
- return BuiltinExtensionChatCompletionsService(
- extensionIdentifier: model.id,
- requestBody: requestBody
- )
- }
-
- switch model.format {
- case .googleAI:
- return GoogleAIChatCompletionsService(
- apiKey: apiKey,
- model: model,
- requestBody: requestBody,
- prompt: prompt,
- baseURL: endpoint.absoluteString
- )
- case .openAI, .openAICompatible, .azureOpenAI:
- return OpenAIChatCompletionsService(
- apiKey: apiKey,
- model: model,
- endpoint: endpoint,
- requestBody: requestBody
- )
- case .ollama:
- return OllamaChatCompletionsService(
- apiKey: apiKey,
- model: model,
- endpoint: endpoint,
- requestBody: requestBody
- )
- case .claude:
- return ClaudeChatCompletionsService(
- apiKey: apiKey,
- model: model,
- endpoint: endpoint,
- requestBody: requestBody
- )
+public extension ChatGPTResponseStream {
+ func asText() async throws -> String {
+ var text = ""
+ for try await case let .partialText(response) in self {
+ text += response
}
+ return text
}
- var buildCompletionAPI: ChatCompletionsAPIBuilder = {
- apiKey, model, endpoint, requestBody, prompt in
-
- if model.id == "com.github.copilot" {
- return BuiltinExtensionChatCompletionsService(
- extensionIdentifier: model.id,
- requestBody: requestBody
- )
+ func asToolCalls() async throws -> [ChatMessage.ToolCall] {
+ var toolCalls = [ChatMessage.ToolCall]()
+ for try await case let .toolCalls(calls) in self {
+ toolCalls.append(contentsOf: calls)
}
+ return toolCalls
+ }
- switch model.format {
- case .googleAI:
- return GoogleAIChatCompletionsService(
- apiKey: apiKey,
- model: model,
- requestBody: requestBody,
- prompt: prompt,
- baseURL: endpoint.absoluteString
- )
- case .openAI, .openAICompatible, .azureOpenAI:
- return OpenAIChatCompletionsService(
- apiKey: apiKey,
- model: model,
- endpoint: endpoint,
- requestBody: requestBody
- )
- case .ollama:
- return OllamaChatCompletionsService(
- apiKey: apiKey,
- model: model,
- endpoint: endpoint,
- requestBody: requestBody
- )
- case .claude:
- return ClaudeChatCompletionsService(
- apiKey: apiKey,
- model: model,
- endpoint: endpoint,
- requestBody: requestBody
- )
+ func asArray() async throws -> [ChatGPTResponse] {
+ var responses = [ChatGPTResponse]()
+ for try await response in self {
+ responses.append(response)
}
+ return responses
}
+}
+
+public protocol ChatGPTServiceType {
+ typealias Response = ChatGPTResponse
+ var configuration: ChatGPTConfiguration { get set }
+ func send(_ memory: ChatGPTMemory) -> ChatGPTResponseStream
+}
+
+public class ChatGPTService: ChatGPTServiceType {
+ public var configuration: ChatGPTConfiguration
+ public var functionProvider: ChatGPTFunctionProvider
public init(
- memory: ChatGPTMemory = AutoManagedChatGPTMemory(
- systemPrompt: "",
- configuration: UserPreferenceChatGPTConfiguration(),
- functionProvider: NoChatGPTFunctionProvider()
- ),
configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(),
functionProvider: ChatGPTFunctionProvider = NoChatGPTFunctionProvider()
) {
- self.memory = memory
self.configuration = configuration
self.functionProvider = functionProvider
}
@Dependency(\.uuid) var uuid
@Dependency(\.date) var date
-
- /// Send a message and stream the reply.
- public func send(
- content: String,
- summary: String? = nil
- ) async throws -> AsyncThrowingStream {
- if !content.isEmpty || summary != nil {
- let newMessage = ChatMessage(
- id: uuid().uuidString,
- role: .user,
- content: content,
- name: nil,
- toolCalls: nil,
- summary: summary,
- references: []
- )
- await memory.appendMessage(newMessage)
- }
-
+ @Dependency(\.chatCompletionsAPIBuilder) var chatCompletionsAPIBuilder
+
+ /// Send the memory and stream the reply. While it's returning the results in a
+ /// ``ChatGPTResponseStream``, it's also streaming the results to the memory.
+ ///
+ /// If ``ChatGPTConfiguration/runFunctionsAutomatically`` is enabled, the service will handle
+ /// the tool calls inside the function. Otherwise, it will return the tool calls to the caller.
+ public func send(_ memory: ChatGPTMemory) -> ChatGPTResponseStream {
return Debugger.$id.withValue(.init()) {
- AsyncThrowingStream { continuation in
+ ChatGPTResponseStream { continuation in
let task = Task(priority: .userInitiated) {
do {
var pendingToolCalls = [ChatMessage.ToolCall]()
var sourceMessageId = ""
var isInitialCall = true
+
loop: while !pendingToolCalls.isEmpty || isInitialCall {
try Task.checkCancellation()
isInitialCall = false
- for toolCall in pendingToolCalls {
- if !configuration.runFunctionsAutomatically {
- break loop
+
+ var functionCallResponses = [ChatCompletionsRequestBody.Message]()
+
+ if !pendingToolCalls.isEmpty {
+ if configuration.runFunctionsAutomatically {
+ for toolCall in pendingToolCalls {
+ for await response in await runFunctionCall(
+ toolCall,
+ memory: memory,
+ sourceMessageId: sourceMessageId
+ ) {
+ switch response {
+ case let .output(output):
+ functionCallResponses.append(.init(
+ role: .tool,
+ content: output,
+ toolCallId: toolCall.id
+ ))
+ case let .status(status):
+ continuation.yield(.status(status))
+ }
+ }
+ }
+ } else {
+ if !configuration.runFunctionsAutomatically {
+ continuation.yield(.toolCalls(pendingToolCalls))
+ continuation.finish()
+ return
+ }
}
- await runFunctionCall(
- toolCall,
- sourceMessageId: sourceMessageId
- )
}
- sourceMessageId = uuid()
- .uuidString + String(date().timeIntervalSince1970)
- let stream = try await sendMemory(proposedId: sourceMessageId)
- #if DEBUG
- var reply = ""
- #endif
+ sourceMessageId = uuid().uuidString
+ let stream = try await sendRequest(
+ memory: memory,
+ proposedMessageId: sourceMessageId
+ )
for try await content in stream {
try Task.checkCancellation()
switch content {
- case let .text(text):
- continuation.yield(text)
- #if DEBUG
- reply.append(text)
- #endif
-
- case let .toolCall(toolCall):
- await prepareFunctionCall(
- toolCall,
- sourceMessageId: sourceMessageId
- )
+ case let .partialText(text):
+ continuation.yield(.partialText(text))
+
+ case let .partialToolCalls(toolCalls):
+ guard configuration.runFunctionsAutomatically else { break }
+ for toolCall in toolCalls.keys.sorted() {
+ if let toolCallValue = toolCalls[toolCall] {
+ for await status in await prepareFunctionCall(
+ toolCallValue,
+ memory: memory,
+ sourceMessageId: sourceMessageId
+ ) {
+ continuation.yield(.status(status))
+ }
+ }
+ }
}
}
- pendingToolCalls = await memory.history
- .last { $0.id == sourceMessageId }?
- .toolCalls ?? []
+ let replyMessage = await memory.history
+ .last { $0.id == sourceMessageId }
+ pendingToolCalls = replyMessage?.toolCalls ?? []
#if DEBUG
- Debugger.didReceiveResponse(content: reply)
+ Debugger.didReceiveResponse(content: replyMessage?.content ?? "")
#endif
}
@@ -276,65 +218,26 @@ public class ChatGPTService: ChatGPTServiceType {
}
}
}
-
- /// Send a message and get the reply in return.
- public func sendAndWait(
- content: String,
- summary: String? = nil
- ) async throws -> String? {
- if !content.isEmpty || summary != nil {
- let newMessage = ChatMessage(
- id: uuid().uuidString,
- role: .user,
- content: content,
- summary: summary
- )
- await memory.appendMessage(newMessage)
- }
- return try await Debugger.$id.withValue(.init()) {
- let message = try await sendMemoryAndWait()
- var finalResult = message?.content
- var toolCalls = message?.toolCalls
- while let sourceMessageId = message?.id, let calls = toolCalls, !calls.isEmpty {
- try Task.checkCancellation()
- if !configuration.runFunctionsAutomatically {
- break
- }
- toolCalls = nil
- for call in calls {
- await runFunctionCall(call, sourceMessageId: sourceMessageId)
- }
- guard let nextMessage = try await sendMemoryAndWait() else { break }
- finalResult = nextMessage.content
- toolCalls = nextMessage.toolCalls
- }
-
- #if DEBUG
- Debugger.didReceiveResponse(content: finalResult ?? "N/A")
- Debugger.didFinish()
- #endif
-
- return finalResult
- }
- }
-
- #warning("TODO: Move the cancellation up to the caller.")
- public func stopReceivingMessage() {
- runningTask?.cancel()
- runningTask = nil
- }
}
// - MARK: Internal
extension ChatGPTService {
enum StreamContent {
- case text(String)
- case toolCall(ChatMessage.ToolCall)
+ case partialText(String)
+ case partialToolCalls([Int: ChatMessage.ToolCall])
+ }
+
+ enum FunctionCallResult {
+ case status(String)
+ case output(String)
}
/// Send the memory as prompt to ChatGPT, with stream enabled.
- func sendMemory(proposedId: String) async throws -> AsyncThrowingStream {
+ func sendRequest(
+ memory: ChatGPTMemory,
+ proposedMessageId: String
+ ) async throws -> AsyncThrowingStream {
let prompt = await memory.generatePrompt()
guard let model = configuration.model else {
@@ -346,12 +249,11 @@ extension ChatGPTService {
let requestBody = createRequestBody(prompt: prompt, model: model, stream: true)
- let api = buildCompletionStreamAPI(
- configuration.apiKey,
- model,
- url,
- requestBody,
- prompt
+ let api = chatCompletionsAPIBuilder.buildStreamAPI(
+ model: model,
+ endpoint: url,
+ apiKey: configuration.apiKey,
+ requestBody: requestBody
)
#if DEBUG
@@ -362,15 +264,13 @@ extension ChatGPTService {
let task = Task {
do {
await memory.streamMessage(
- id: proposedId,
+ id: proposedMessageId,
role: .assistant,
references: prompt.references
)
let chunks = try await api()
for try await chunk in chunks {
- if Task.isCancelled {
- throw CancellationError()
- }
+ try Task.checkCancellation()
guard let delta = chunk.message else { continue }
// The api will always return a function call with JSON object.
@@ -390,23 +290,19 @@ extension ChatGPTService {
}
await memory.streamMessage(
- id: proposedId,
+ id: proposedMessageId,
role: delta.role?.asChatMessageRole,
content: delta.content,
toolCalls: toolCalls
)
if let toolCalls {
- for toolCall in toolCalls.values {
- continuation.yield(.toolCall(toolCall))
- }
+ continuation.yield(.partialToolCalls(toolCalls))
}
if let content = delta.content {
- continuation.yield(.text(content))
+ continuation.yield(.partialText(content))
}
-
- try await Task.sleep(nanoseconds: 3_000_000)
}
continuation.finish()
@@ -416,6 +312,7 @@ extension ChatGPTService {
continuation.finish(throwing: error)
} catch {
await memory.appendMessage(.init(
+ id: uuid().uuidString,
role: .assistant,
content: error.localizedDescription
))
@@ -423,145 +320,132 @@ extension ChatGPTService {
}
}
- runningTask = task
-
continuation.onTermination = { _ in
task.cancel()
}
}
}
- /// Send the memory as prompt to ChatGPT, with stream disabled.
- func sendMemoryAndWait() async throws -> ChatMessage? {
- let proposedId = uuid().uuidString + String(date().timeIntervalSince1970)
- let prompt = await memory.generatePrompt()
-
- guard let model = configuration.model else {
- throw ChatGPTServiceError.chatModelNotAvailable
- }
- guard let url = URL(string: configuration.endpoint) else {
- throw ChatGPTServiceError.endpointIncorrect
- }
-
- let requestBody = createRequestBody(prompt: prompt, model: model, stream: false)
-
- let api = buildCompletionAPI(
- configuration.apiKey,
- model,
- url,
- requestBody,
- prompt
- )
-
- #if DEBUG
- Debugger.didSendRequestBody(body: requestBody)
- #endif
-
- let response = try await api()
-
- let choice = response.message
- let message = ChatMessage(
- id: proposedId,
- role: {
- switch choice.role {
- case .system: .system
- case .user: .user
- case .assistant: .assistant
- case .tool: .user
+ /// When a function call is detected, but arguments are not yet ready, we can call this
+ /// to report the status.
+ func prepareFunctionCall(
+ _ call: ChatMessage.ToolCall,
+ memory: ChatGPTMemory,
+ sourceMessageId: String
+ ) async -> AsyncStream {
+ return .init { continuation in
+ guard let function = functionProvider.function(named: call.function.name) else {
+ continuation.finish()
+ return
+ }
+ let task = Task {
+ await memory.streamToolCallResponse(
+ id: sourceMessageId,
+ toolCallId: call.id
+ )
+ await function.prepare { summary in
+ continuation.yield(summary)
+ await memory.streamToolCallResponse(
+ id: sourceMessageId,
+ toolCallId: call.id,
+ summary: summary
+ )
}
- }(),
- content: choice.content,
- name: choice.name,
- toolCalls: choice.toolCalls?.map {
- ChatMessage.ToolCall(id: $0.id, type: $0.type, function: .init(
- name: $0.function.name,
- arguments: $0.function.arguments ?? ""
- ))
- },
- references: prompt.references
- )
- await memory.appendMessage(message)
- return message
- }
+ continuation.finish()
+ }
- /// When a function call is detected, but arguments are not yet ready, we can call this
- /// to insert a message placeholder in memory.
- func prepareFunctionCall(_ call: ChatMessage.ToolCall, sourceMessageId: String) async {
- guard let function = functionProvider.function(named: call.function.name) else { return }
- await memory.streamToolCallResponse(id: sourceMessageId, toolCallId: call.id)
- await function.prepare { [weak self] summary in
- await self?.memory.streamToolCallResponse(
- id: sourceMessageId,
- toolCallId: call.id,
- summary: summary
- )
+ continuation.onTermination = { _ in
+ task.cancel()
+ }
}
}
- /// Run a function call from the bot, and insert the result in memory.
+ /// Run a function call from the bot.
@discardableResult
func runFunctionCall(
_ call: ChatMessage.ToolCall,
+ memory: ChatGPTMemory,
sourceMessageId: String
- ) async -> String {
+ ) async -> AsyncStream {
#if DEBUG
Debugger.didReceiveFunction(name: call.function.name, arguments: call.function.arguments)
#endif
- guard let function = functionProvider.function(named: call.function.name) else {
- return await fallbackFunctionCall(call, sourceMessageId: sourceMessageId)
- }
-
- await memory.streamToolCallResponse(
- id: sourceMessageId,
- toolCallId: call.id
- )
+ return .init { continuation in
+ let task = Task {
+ guard let function = functionProvider.function(named: call.function.name) else {
+ let response = await fallbackFunctionCall(
+ call,
+ memory: memory,
+ sourceMessageId: sourceMessageId
+ )
+ continuation.yield(.output(response))
+ continuation.finish()
+ return
+ }
- do {
- // Run the function
- let result = try await function.call(argumentsJsonString: call.function.arguments) {
- [weak self] summary in
- await self?.memory.streamToolCallResponse(
+ await memory.streamToolCallResponse(
id: sourceMessageId,
- toolCallId: call.id,
- summary: summary
+ toolCallId: call.id
)
- }
- #if DEBUG
- Debugger.didReceiveFunctionResult(result: result.botReadableContent)
- #endif
+ do {
+ // Run the function
+ let result = try await function
+ .call(argumentsJsonString: call.function.arguments) { summary in
+ continuation.yield(.status(summary))
+ await memory.streamToolCallResponse(
+ id: sourceMessageId,
+ toolCallId: call.id,
+ summary: summary
+ )
+ }
+
+ #if DEBUG
+ Debugger.didReceiveFunctionResult(result: result.botReadableContent)
+ #endif
- await memory.streamToolCallResponse(
- id: sourceMessageId,
- toolCallId: call.id,
- content: result.botReadableContent
- )
+ await memory.streamToolCallResponse(
+ id: sourceMessageId,
+ toolCallId: call.id,
+ content: result.botReadableContent
+ )
- return result.botReadableContent
- } catch {
- // For errors, use the error message as the result.
- let content = "Error: \(error.localizedDescription)"
+ continuation.yield(.output(result.botReadableContent))
+ continuation.finish()
+ } catch {
+ // For errors, use the error message as the result.
+ let content = "Error: \(error.localizedDescription)"
+
+ #if DEBUG
+ Debugger.didReceiveFunctionResult(result: content)
+ #endif
+
+ await memory.streamToolCallResponse(
+ id: sourceMessageId,
+ toolCallId: call.id,
+ content: content,
+ summary: content
+ )
- #if DEBUG
- Debugger.didReceiveFunctionResult(result: content)
- #endif
+ continuation.yield(.output(content))
+ continuation.finish()
+ }
+ }
- await memory.streamToolCallResponse(
- id: sourceMessageId,
- toolCallId: call.id,
- content: content
- )
- return content
+ continuation.onTermination = { _ in
+ task.cancel()
+ }
}
}
/// Mock a function call result when the bot is calling a function that is not implemented.
func fallbackFunctionCall(
_ call: ChatMessage.ToolCall,
+ memory: ChatGPTMemory,
sourceMessageId: String
) async -> String {
- let memory = ConversationChatGPTMemory(systemPrompt: {
+ let temporaryMemory = ConversationChatGPTMemory(systemPrompt: {
if call.function.name == "python" {
return """
Act like a Python interpreter.
@@ -579,29 +463,29 @@ extension ChatGPTService {
}())
let service = ChatGPTService(
- memory: memory,
- configuration: OverridingChatGPTConfiguration(overriding: configuration, with: .init(
- temperature: 0
- )),
+ configuration: OverridingChatGPTConfiguration(
+ overriding: UserPreferenceChatGPTConfiguration(
+ chatModelKey: \.preferredChatModelIdForUtilities
+ ),
+ with: .init(temperature: 0)
+ ),
functionProvider: NoChatGPTFunctionProvider()
)
- let content: String = await {
- do {
- return try await service.sendAndWait(content: """
- \(call.function.arguments)
- """) ?? "No result."
- } catch {
- return "No result."
- }
- }()
- await memory.streamToolCallResponse(
- id: sourceMessageId,
- toolCallId: call.id,
- content: content,
- summary: "Finished running function."
- )
- return content
+ let stream = service.send(temporaryMemory)
+
+ do {
+ let result = try await stream.asText()
+ await memory.streamToolCallResponse(
+ id: sourceMessageId,
+ toolCallId: call.id,
+ content: result,
+ summary: "Finished running function."
+ )
+ return result
+ } catch {
+ return error.localizedDescription
+ }
}
func createRequestBody(
@@ -692,16 +576,11 @@ extension ChatGPTService {
return requestBody
}
-}
-
-extension ChatGPTService {
- func changeBuildCompletionStreamAPI(_ builder: @escaping ChatCompletionsStreamAPIBuilder) {
- buildCompletionStreamAPI = builder
+
+
+ func maxTokenForReply(maxToken: Int, remainingTokens: Int?) -> Int? {
+ guard let remainingTokens else { return nil }
+ return min(maxToken / 2, remainingTokens)
}
}
-func maxTokenForReply(maxToken: Int, remainingTokens: Int?) -> Int? {
- guard let remainingTokens else { return nil }
- return min(maxToken / 2, remainingTokens)
-}
-
diff --git a/Tool/Sources/OpenAIService/LegacyChatGPTService.swift b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift
new file mode 100644
index 00000000..d33f7e7f
--- /dev/null
+++ b/Tool/Sources/OpenAIService/LegacyChatGPTService.swift
@@ -0,0 +1,108 @@
+import AIModel
+import AsyncAlgorithms
+import ChatBasic
+import Dependencies
+import Foundation
+import IdentifiedCollections
+import Preferences
+
+@available(*, deprecated, message: "Use ChatGPTServiceType instead.")
+public protocol LegacyChatGPTServiceType {
+ var memory: ChatGPTMemory { get set }
+ var configuration: ChatGPTConfiguration { get set }
+ func send(content: String, summary: String?) async throws -> AsyncThrowingStream
+ func stopReceivingMessage() async
+}
+
+@available(*, deprecated, message: "Use ChatGPTServiceType instead.")
+public class LegacyChatGPTService: LegacyChatGPTServiceType {
+ public var memory: ChatGPTMemory
+ public var configuration: ChatGPTConfiguration
+ public var functionProvider: ChatGPTFunctionProvider
+
+ var runningTask: Task, Never>?
+
+ public init(
+ memory: ChatGPTMemory = AutoManagedChatGPTMemory(
+ systemPrompt: "",
+ configuration: UserPreferenceChatGPTConfiguration(),
+ functionProvider: NoChatGPTFunctionProvider(),
+ maxNumberOfMessages: .max
+ ),
+ configuration: ChatGPTConfiguration = UserPreferenceChatGPTConfiguration(),
+ functionProvider: ChatGPTFunctionProvider = NoChatGPTFunctionProvider()
+ ) {
+ self.memory = memory
+ self.configuration = configuration
+ self.functionProvider = functionProvider
+ }
+
+ @Dependency(\.uuid) var uuid
+ @Dependency(\.date) var date
+ @Dependency(\.chatCompletionsAPIBuilder) var chatCompletionsAPIBuilder
+
+ /// Send a message and stream the reply.
+ public func send(
+ content: String,
+ summary: String? = nil
+ ) async throws -> AsyncThrowingStream {
+ let task = Task {
+ if !content.isEmpty || summary != nil {
+ let newMessage = ChatMessage(
+ id: uuid().uuidString,
+ role: .user,
+ content: content,
+ name: nil,
+ toolCalls: nil,
+ summary: summary,
+ references: []
+ )
+ await memory.appendMessage(newMessage)
+ }
+
+ let service = ChatGPTService(
+ configuration: configuration,
+ functionProvider: functionProvider
+ )
+
+ let responses = service.send(memory)
+
+ return responses.compactMap { response in
+ switch response {
+ case let .partialText(token): return token
+ default: return nil
+ }
+ }.eraseToThrowingStream()
+ }
+ runningTask = task
+ return await task.value
+ }
+
+ /// Send a message and get the reply in return.
+ public func sendAndWait(
+ content: String,
+ summary: String? = nil
+ ) async throws -> String? {
+ if !content.isEmpty || summary != nil {
+ let newMessage = ChatMessage(
+ id: uuid().uuidString,
+ role: .user,
+ content: content,
+ summary: summary
+ )
+ await memory.appendMessage(newMessage)
+ }
+
+ let service = ChatGPTService(
+ configuration: configuration,
+ functionProvider: functionProvider
+ )
+
+ return try await service.send(memory).asText()
+ }
+
+ public func stopReceivingMessage() {
+ runningTask?.cancel()
+ }
+}
+
diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift
index 237ce417..6da5e86b 100644
--- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift
+++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemory.swift
@@ -38,6 +38,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory {
public var retrievedContent: [ChatMessage.Reference] = []
public var configuration: ChatGPTConfiguration
public var functionProvider: ChatGPTFunctionProvider
+ public var maxNumberOfMessages: Int
var onHistoryChange: () -> Void = {}
@@ -47,6 +48,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory {
systemPrompt: String,
configuration: ChatGPTConfiguration,
functionProvider: ChatGPTFunctionProvider,
+ maxNumberOfMessages: Int = .max,
composeHistory: @escaping HistoryComposer = {
/// Default Format:
/// ```
@@ -70,6 +72,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory {
self.configuration = configuration
self.functionProvider = functionProvider
self.composeHistory = composeHistory
+ self.maxNumberOfMessages = maxNumberOfMessages
}
public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) {
@@ -110,10 +113,7 @@ public actor AutoManagedChatGPTMemory: ChatGPTMemory {
extension AutoManagedChatGPTMemory {
/// https://github.com/openai/openai-cookbook/blob/main/examples/How_to_count_tokens_with_tiktoken.ipynb
- func generateSendingHistory(
- maxNumberOfMessages: Int = UserDefaults.shared.value(for: \.chatGPTMaxMessageCount),
- strategy: AutoManagedChatGPTMemoryStrategy
- ) async -> ChatGPTPrompt {
+ func generateSendingHistory(strategy: AutoManagedChatGPTMemoryStrategy) async -> ChatGPTPrompt {
// handle no function support models
let (
diff --git a/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift
new file mode 100644
index 00000000..129474aa
--- /dev/null
+++ b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift
@@ -0,0 +1,256 @@
+import ChatBasic
+import Foundation
+import Logger
+import Preferences
+import TokenEncoder
+
+/// A memory that automatically manages the history according to max tokens and the template rules.
+public actor TemplateChatGPTMemory: ChatGPTMemory {
+ public private(set) var memoryTemplate: MemoryTemplate
+ public var history: [ChatMessage] { memoryTemplate.resolved() }
+ public var configuration: ChatGPTConfiguration
+ public var functionProvider: ChatGPTFunctionProvider
+
+ public init(
+ memoryTemplate: MemoryTemplate,
+ configuration: ChatGPTConfiguration,
+ functionProvider: ChatGPTFunctionProvider
+ ) {
+ self.memoryTemplate = memoryTemplate
+ self.configuration = configuration
+ self.functionProvider = functionProvider
+ }
+
+ public func mutateHistory(_ update: (inout [ChatMessage]) -> Void) async {
+ update(&memoryTemplate.followUpMessages)
+ }
+
+ public func generatePrompt() async -> ChatGPTPrompt {
+ let strategy: AutoManagedChatGPTMemoryStrategy = switch configuration.model?.format {
+ case .googleAI: AutoManagedChatGPTMemory.GoogleAIStrategy(configuration: configuration)
+ default: AutoManagedChatGPTMemory.OpenAIStrategy()
+ }
+
+ var memoryTemplate = self.memoryTemplate
+ func checkTokenCount() async -> Bool {
+ let history = self.history
+ var tokenCount = 0
+ for message in history {
+ tokenCount += await strategy.countToken(message)
+ }
+ for function in functionProvider.functions {
+ tokenCount += await strategy.countToken(function)
+ }
+ return tokenCount <= configuration.maxTokens - configuration.minimumReplyTokens
+ }
+
+ while !(await checkTokenCount()) {
+ do {
+ try memoryTemplate.truncate()
+ } catch {
+ Logger.service.error("Failed to truncate prompt template: \(error)")
+ break
+ }
+ }
+
+ return ChatGPTPrompt(history: memoryTemplate.resolved())
+ }
+}
+
+public struct MemoryTemplate {
+ public struct Message {
+ public struct DynamicContent: ExpressibleByStringLiteral {
+ public enum Content: ExpressibleByStringLiteral {
+ case text(String)
+ case list([String], formatter: ([String]) -> String)
+
+ public init(stringLiteral value: String) {
+ self = .text(value)
+ }
+ }
+
+ public var content: Content
+ public var truncatePriority: Int = 0
+ public var isEmpty: Bool {
+ switch content {
+ case let .text(text):
+ return text.isEmpty
+ case let .list(list, _):
+ return list.isEmpty
+ }
+ }
+
+ public init(stringLiteral value: String) {
+ content = .text(value)
+ }
+
+ public init(content: Content, truncatePriority: Int = 0) {
+ self.content = content
+ self.truncatePriority = truncatePriority
+ }
+ }
+
+ public var chatMessage: ChatMessage
+ public var dynamicContent: [DynamicContent] = []
+ public var truncatePriority: Int = 0
+
+ public func resolved() -> ChatMessage? {
+ var baseMessage = chatMessage
+ guard !dynamicContent.isEmpty else {
+ if baseMessage.isEmpty { return nil }
+ return baseMessage
+ }
+
+ let contents: [String] = dynamicContent.compactMap { content in
+ if content.isEmpty { return nil }
+ switch content.content {
+ case let .text(text):
+ return text
+ case let .list(list, formatter):
+ return formatter(list)
+ }
+ }
+
+ baseMessage.content = contents.joined(separator: "\n\n")
+
+ return baseMessage
+ }
+
+ public var isEmpty: Bool {
+ if !dynamicContent.isEmpty { return dynamicContent.allSatisfy { $0.isEmpty } }
+ if let toolCalls = chatMessage.toolCalls, !toolCalls.isEmpty {
+ return false
+ }
+ if let content = chatMessage.content, !content.isEmpty {
+ return false
+ }
+ return true
+ }
+
+ public init(
+ chatMessage: ChatMessage,
+ dynamicContent: [DynamicContent] = [],
+ truncatePriority: Int = 0
+ ) {
+ self.chatMessage = chatMessage
+ self.dynamicContent = dynamicContent
+ self.truncatePriority = truncatePriority
+ }
+ }
+
+ public var messages: [Message]
+ public var followUpMessages: [ChatMessage]
+
+ let truncateRule: ((
+ _ messages: inout [Message],
+ _ followUpMessages: inout [ChatMessage]
+ ) throws -> Void)?
+
+ func resolved() -> [ChatMessage] {
+ messages.compactMap { message in message.resolved() } + followUpMessages
+ }
+
+ func truncated() throws -> MemoryTemplate {
+ var copy = self
+ try copy.truncate()
+ return copy
+ }
+
+ mutating func truncate() throws {
+ if let truncateRule = truncateRule {
+ try truncateRule(&messages, &followUpMessages)
+ return
+ }
+
+ try Self.defaultTruncateRule(&messages, &followUpMessages)
+ }
+
+ public static func defaultTruncateRule(
+ _ messages: inout [Message],
+ _ followUpMessages: inout [ChatMessage]
+ ) throws {
+ // Remove the oldest followup messages when available.
+
+ if followUpMessages.count > 20 {
+ followUpMessages.removeFirst(followUpMessages.count / 2)
+ return
+ }
+
+ if followUpMessages.count > 2 {
+ if followUpMessages.count.isMultiple(of: 2) {
+ followUpMessages.removeFirst(2)
+ } else {
+ followUpMessages.removeFirst(1)
+ }
+ return
+ }
+
+ // Remove according to the priority.
+
+ var truncatingMessageIndex: Int?
+ for (index, message) in messages.enumerated() {
+ if message.truncatePriority <= 0 { continue }
+ if let previousIndex = truncatingMessageIndex,
+ message.truncatePriority > messages[previousIndex].truncatePriority
+ {
+ truncatingMessageIndex = index
+ }
+ }
+
+ guard let truncatingMessageIndex else { throw CancellationError() }
+ var truncatingMessage: Message {
+ get { messages[truncatingMessageIndex] }
+ set { messages[truncatingMessageIndex] = newValue }
+ }
+
+ if truncatingMessage.isEmpty {
+ messages.remove(at: truncatingMessageIndex)
+ return
+ }
+
+ truncatingMessage.dynamicContent.removeAll(where: { $0.isEmpty })
+
+ var truncatingContentIndex: Int?
+ for (index, content) in truncatingMessage.dynamicContent.enumerated() {
+ if content.isEmpty { continue }
+ if let previousIndex = truncatingContentIndex,
+ content.truncatePriority > truncatingMessage.dynamicContent[previousIndex]
+ .truncatePriority
+ {
+ truncatingContentIndex = index
+ }
+ }
+
+ guard let truncatingContentIndex else { throw CancellationError() }
+ var truncatingContent: Message.DynamicContent {
+ get { truncatingMessage.dynamicContent[truncatingContentIndex] }
+ set { truncatingMessage.dynamicContent[truncatingContentIndex] = newValue }
+ }
+
+ switch truncatingContent.content {
+ case .text:
+ truncatingMessage.dynamicContent.remove(at: truncatingContentIndex)
+ case let .list(list, formatter: formatter):
+ let count = list.count * 2 / 3
+ if count > 0 {
+ truncatingContent.content = .list(
+ Array(list.prefix(count)),
+ formatter: formatter
+ )
+ } else {
+ truncatingMessage.dynamicContent.remove(at: truncatingContentIndex)
+ }
+ }
+ }
+
+ public init(
+ messages: [Message],
+ followUpMessages: [ChatMessage] = [],
+ truncateRule: ((inout [Message], inout [ChatMessage]) -> Void)? = nil
+ ) {
+ self.messages = messages
+ self.truncateRule = truncateRule
+ self.followUpMessages = followUpMessages
+ }
+}
+
diff --git a/Tool/Sources/Preferences/Types/ChatGPTModel.swift b/Tool/Sources/Preferences/Types/ChatGPTModel.swift
index 9670074c..8ac04faf 100644
--- a/Tool/Sources/Preferences/Types/ChatGPTModel.swift
+++ b/Tool/Sources/Preferences/Types/ChatGPTModel.swift
@@ -1,9 +1,10 @@
import Foundation
-public enum ChatGPTModel: String {
+public enum ChatGPTModel: String, CaseIterable {
case gpt35Turbo = "gpt-3.5-turbo"
case gpt35Turbo16k = "gpt-3.5-turbo-16k"
case gpt4o = "gpt-4o"
+ case gpt4oMini = "gpt-4o-mini"
case gpt4 = "gpt-4"
case gpt432k = "gpt-4-32k"
case gpt4Turbo = "gpt-4-turbo"
@@ -13,13 +14,15 @@ public enum ChatGPTModel: String {
case gpt4VisionPreview = "gpt-4-vision-preview"
case gpt4TurboPreview = "gpt-4-turbo-preview"
case gpt4Turbo20240409 = "gpt-4-turbo-2024-04-09"
- case gpt35Turbo0613 = "gpt-3.5-turbo-0613"
case gpt35Turbo1106 = "gpt-3.5-turbo-1106"
case gpt35Turbo0125 = "gpt-3.5-turbo-0125"
- case gpt35Turbo16k0613 = "gpt-3.5-turbo-16k-0613"
case gpt432k0314 = "gpt-4-32k-0314"
case gpt432k0613 = "gpt-4-32k-0613"
case gpt40125 = "gpt-4-0125-preview"
+ case o1Preview = "o1-preview"
+ case o1Preview20240912 = "o1-preview-2024-09-12"
+ case o1Mini = "o1-mini"
+ case o1Mini20240912 = "o1-mini-2024-09-12"
}
public extension ChatGPTModel {
@@ -35,40 +38,43 @@ public extension ChatGPTModel {
return 32768
case .gpt35Turbo:
return 16385
- case .gpt35Turbo0613:
- return 4096
case .gpt35Turbo1106:
return 16385
case .gpt35Turbo0125:
return 16385
case .gpt35Turbo16k:
return 16385
- case .gpt35Turbo16k0613:
- return 16385
case .gpt40613:
return 8192
case .gpt432k0613:
return 32768
case .gpt41106Preview:
- return 128000
+ return 128_000
case .gpt4VisionPreview:
- return 128000
+ return 128_000
case .gpt4TurboPreview:
- return 128000
+ return 128_000
case .gpt40125:
- return 128000
+ return 128_000
case .gpt4Turbo:
- return 128000
+ return 128_000
case .gpt4Turbo20240409:
- return 128000
+ return 128_000
case .gpt4o:
- return 128000
+ return 128_000
+ case .gpt4oMini:
+ return 128_000
+ case .o1Preview, .o1Preview20240912:
+ return 128_000
+ case .o1Mini, .o1Mini20240912:
+ return 128_000
}
}
-
+
var supportsImages: Bool {
switch self {
- case .gpt4VisionPreview, .gpt4Turbo, .gpt4Turbo20240409, .gpt4o:
+ case .gpt4VisionPreview, .gpt4Turbo, .gpt4Turbo20240409, .gpt4o, .gpt4oMini, .o1Preview,
+ .o1Preview20240912, .o1Mini, .o1Mini20240912:
return true
default:
return false
@@ -76,4 +82,3 @@ public extension ChatGPTModel {
}
}
-extension ChatGPTModel: CaseIterable {}
diff --git a/Tool/Sources/PromptToCodeBasic/PromptToCodeAgent.swift b/Tool/Sources/PromptToCodeBasic/PromptToCodeAgent.swift
new file mode 100644
index 00000000..321b152b
--- /dev/null
+++ b/Tool/Sources/PromptToCodeBasic/PromptToCodeAgent.swift
@@ -0,0 +1,91 @@
+import ComposableArchitecture
+import Foundation
+import SuggestionBasic
+
+public enum PromptToCodeAgentResponse {
+ case code(String)
+ case description(String)
+}
+
+public struct PromptToCodeAgentRequest {
+ var code: String
+ var requirement: String
+ var source: PromptToCodeSource
+ var isDetached: Bool
+ var extraSystemPrompt: String?
+ var generateDescriptionRequirement: Bool?
+
+ public struct PromptToCodeSource {
+ public var language: CodeLanguage
+ public var documentURL: URL
+ public var projectRootURL: URL
+ public var content: String
+ public var lines: [String]
+ public var range: CursorRange
+
+ public init(
+ language: CodeLanguage,
+ documentURL: URL,
+ projectRootURL: URL,
+ content: String,
+ lines: [String],
+ range: CursorRange
+ ) {
+ self.language = language
+ self.documentURL = documentURL
+ self.projectRootURL = projectRootURL
+ self.content = content
+ self.lines = lines
+ self.range = range
+ }
+ }
+}
+
+public protocol PromptToCodeAgent {
+ typealias Request = PromptToCodeAgentRequest
+ typealias Response = PromptToCodeAgentResponse
+
+ func send(_ request: Request) -> AsyncThrowingStream
+}
+
+public struct PromptToCodeSnippet: Equatable, Identifiable {
+ public let id = UUID()
+ public var startLineIndex: Int
+ public var originalCode: String
+ public var modifiedCode: String
+ public var description: String
+ public var error: String?
+ public var attachedRange: CursorRange
+
+ public init(
+ startLineIndex: Int,
+ originalCode: String,
+ modifiedCode: String,
+ description: String,
+ error: String?,
+ attachedRange: CursorRange
+ ) {
+ self.startLineIndex = startLineIndex
+ self.originalCode = originalCode
+ self.modifiedCode = modifiedCode
+ self.description = description
+ self.error = error
+ self.attachedRange = attachedRange
+ }
+}
+
+public enum PromptToCodeAttachedTarget: Equatable {
+ case file(URL, projectURL: URL, code: String, lines: [String])
+ case dynamic
+}
+
+public struct PromptToCodeHistoryNode: Equatable {
+ public var snippets: IdentifiedArrayOf
+ public var instruction: String
+
+ public init(snippets: IdentifiedArrayOf, instruction: String) {
+ self.snippets = snippets
+ self.instruction = instruction
+ }
+}
+
diff --git a/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift
new file mode 100644
index 00000000..703398cd
--- /dev/null
+++ b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift
@@ -0,0 +1,156 @@
+import ComposableArchitecture
+import Dependencies
+import Foundation
+import PromptToCodeBasic
+import SuggestionBasic
+import SwiftUI
+
+public enum PromptToCodeCustomization {
+ public static var CustomizedUI: any PromptToCodeCustomizedUI = NoPromptToCodeCustomizedUI()
+}
+
+public struct PromptToCodeCustomizationContextWrapper: View {
+ @State var context: AnyObject
+ let content: (AnyObject) -> Content
+
+ init(context: O, @ViewBuilder content: @escaping (O) -> Content) {
+ self.context = context
+ self.content = { context in
+ content(context as! O)
+ }
+ }
+
+ public var body: some View {
+ content(context)
+ }
+}
+
+public protocol PromptToCodeCustomizedUI {
+ typealias PromptToCodeCustomizedViews = (
+ extraMenuItems: AnyView?,
+ extraButtons: AnyView?,
+ extraAcceptButtonVariants: AnyView?,
+ inputField: AnyView?
+ )
+
+ func callAsFunction(
+ state: Shared,
+ isInputFieldFocused: Binding,
+ @ViewBuilder view: @escaping (PromptToCodeCustomizedViews) -> V
+ ) -> PromptToCodeCustomizationContextWrapper
+}
+
+struct NoPromptToCodeCustomizedUI: PromptToCodeCustomizedUI {
+ private class Context {}
+
+ func callAsFunction(
+ state: Shared,
+ isInputFieldFocused: Binding,
+ @ViewBuilder view: @escaping (PromptToCodeCustomizedViews) -> V
+ ) -> PromptToCodeCustomizationContextWrapper {
+ PromptToCodeCustomizationContextWrapper(context: Context()) { _ in
+ view((
+ extraMenuItems: nil,
+ extraButtons: nil,
+ extraAcceptButtonVariants: nil,
+ inputField: nil
+ ))
+ }
+ }
+}
+
+public struct PromptToCodeState: Equatable {
+ public struct Source: Equatable {
+ public var language: CodeLanguage
+ public var documentURL: URL
+ public var projectRootURL: URL
+ public var content: String
+ public var lines: [String]
+
+ public init(
+ language: CodeLanguage,
+ documentURL: URL,
+ projectRootURL: URL,
+ content: String,
+ lines: [String]
+ ) {
+ self.language = language
+ self.documentURL = documentURL
+ self.projectRootURL = projectRootURL
+ self.content = content
+ self.lines = lines
+ }
+ }
+
+ public var source: Source
+ public var history: [PromptToCodeHistoryNode] = []
+ public var snippets: IdentifiedArrayOf = []
+ public var isGenerating: Bool = false
+ public var instruction: String
+ public var extraSystemPrompt: String
+ public var isAttachedToTarget: Bool = true
+
+ public init(
+ source: Source,
+ history: [PromptToCodeHistoryNode] = [],
+ snippets: IdentifiedArrayOf,
+ instruction: String,
+ extraSystemPrompt: String,
+ isAttachedToTarget: Bool
+ ) {
+ self.history = history
+ self.snippets = snippets
+ isGenerating = false
+ self.instruction = instruction
+ self.isAttachedToTarget = isAttachedToTarget
+ self.extraSystemPrompt = extraSystemPrompt
+ self.source = source
+ }
+
+ public init(
+ source: Source,
+ originalCode: String,
+ attachedRange: CursorRange,
+ instruction: String,
+ extraSystemPrompt: String
+ ) {
+ self.init(
+ source: source,
+ snippets: [
+ .init(
+ startLineIndex: 0,
+ originalCode: originalCode,
+ modifiedCode: originalCode,
+ description: "",
+ error: nil,
+ attachedRange: attachedRange
+ ),
+ ],
+ instruction: instruction,
+ extraSystemPrompt: extraSystemPrompt,
+ isAttachedToTarget: !attachedRange.isEmpty
+ )
+ }
+
+ public mutating func popHistory() {
+ if !history.isEmpty {
+ let last = history.removeLast()
+ snippets = last.snippets
+ instruction = last.instruction
+ }
+ }
+
+ public mutating func pushHistory() {
+ history.append(.init(snippets: snippets, instruction: instruction))
+ let oldSnippets = snippets
+ snippets = IdentifiedArrayOf()
+ for var snippet in oldSnippets {
+ snippet.originalCode = snippet.modifiedCode
+ snippet.modifiedCode = ""
+ snippet.description = ""
+ snippet.error = nil
+ snippets.append(snippet)
+ }
+ }
+}
+
diff --git a/Tool/Sources/RAGChatAgent/RAGChatAgent.swift b/Tool/Sources/RAGChatAgent/RAGChatAgent.swift
new file mode 100644
index 00000000..5e7306e9
--- /dev/null
+++ b/Tool/Sources/RAGChatAgent/RAGChatAgent.swift
@@ -0,0 +1,91 @@
+import AIModel
+import ChatBasic
+import Foundation
+import OpenAIService
+
+public class RAGChatAgent: ChatAgent {
+ public let configuration: RAGChatAgentConfiguration
+
+ public init(configuration: RAGChatAgentConfiguration) {
+ self.configuration = configuration
+ }
+
+ public func send(_ request: Request) async -> AsyncThrowingStream {
+ let stream = AsyncThrowingStream { continuation in
+ let task = Task(priority: .userInitiated) {
+ do {
+ let service = try await createService(for: request)
+ let response = try await service.send(content: request.text, summary: nil)
+ for try await item in response {
+ if Task.isCancelled {
+ continuation.finish()
+ return
+ }
+ continuation.yield(.contentToken(item))
+ }
+ continuation.finish()
+ } catch {
+ continuation.finish(throwing: error)
+ }
+ }
+
+ continuation.onTermination = { _ in
+ task.cancel()
+ }
+ }
+
+ return stream
+ }
+}
+
+extension RAGChatAgent {
+ func createService(for request: Request) async throws -> LegacyChatGPTServiceType {
+ guard let chatGPTConfiguration = configuration.chatGPTConfiguration
+ else { throw CancellationError() }
+ let functionProvider = ChatFunctionProvider()
+ let memory = AutoManagedChatGPTMemory(
+ systemPrompt: configuration.modelConfiguration.systemPrompt,
+ configuration: chatGPTConfiguration,
+ functionProvider: functionProvider
+ )
+
+ await memory.mutateHistory { messages in
+ for history in request.history {
+ messages.append(history)
+ }
+ }
+
+ return LegacyChatGPTService(
+ memory: memory,
+ configuration: chatGPTConfiguration,
+ functionProvider: functionProvider
+ )
+ }
+
+ var allCapabilities: [String: any RAGChatAgentCapability] {
+ RAGChatAgentCapabilityContainer.capabilities
+ }
+
+ func capability(for identifier: String) -> (any RAGChatAgentCapability)? {
+ allCapabilities[identifier]
+ }
+}
+
+final class ChatFunctionProvider: ChatGPTFunctionProvider {
+ var functions: [any ChatGPTFunction] = []
+
+ init() {}
+
+ func removeAll() {
+ functions = []
+ }
+
+ func append(functions others: [any ChatGPTFunction]) {
+ functions.append(contentsOf: others)
+ }
+
+ var functionCallStrategy: OpenAIService.FunctionCallStrategy? {
+ nil
+ }
+}
+
diff --git a/Tool/Sources/RAGChatAgent/RAGChatAgentCapability.swift b/Tool/Sources/RAGChatAgent/RAGChatAgentCapability.swift
new file mode 100644
index 00000000..519e3a45
--- /dev/null
+++ b/Tool/Sources/RAGChatAgent/RAGChatAgentCapability.swift
@@ -0,0 +1,59 @@
+import ChatBasic
+import Foundation
+
+/// A singleton that stores all the possible capabilities of an ``RAGChatAgent``.
+public enum RAGChatAgentCapabilityContainer {
+ static var capabilities: [String: any RAGChatAgentCapability] = [:]
+ static func add(_ capability: any RAGChatAgentCapability) {
+ capabilities[capability.id] = capability
+ }
+
+ static func add(_ capabilities: [any RAGChatAgentCapability]) {
+ capabilities.forEach { add($0) }
+ }
+}
+
+/// A protocol that defines the capability of an ``RAGChatAgent``.
+protocol RAGChatAgentCapability: Identifiable {
+ typealias Request = ChatAgentRequest
+ typealias Reference = ChatAgentContext.Reference
+
+ /// The name to be displayed to the user.
+ var name: String { get }
+ /// The identifier of the capability.
+ var id: String { get }
+ /// Fetch the context for a given request. It can return a portion of the context at a time.
+ func fetchContext(for request: ChatAgentRequest) async -> AsyncStream
+}
+
+public struct ChatAgentContext {
+ public typealias Reference = ChatMessage.Reference
+
+ /// Extra system prompt to be included in the chat request.
+ public var extraSystemPrompt: String?
+ /// References to be included in the chat request.
+ public var references: [Reference]
+ /// Functions to be included in the chat request.
+ public var functions: [any ChatGPTFunction]
+
+ public init(
+ extraSystemPrompt: String? = nil,
+ references: [ChatMessage.Reference] = [],
+ functions: [any ChatGPTFunction] = []
+ ) {
+ self.extraSystemPrompt = extraSystemPrompt
+ self.references = references
+ self.functions = functions
+ }
+}
+
+// MARK: - Default Implementation
+
+extension RAGChatAgentCapability {
+ func fetchContext(for request: ChatAgentRequest) async -> AsyncStream {
+ return AsyncStream { continuation in
+ continuation.finish()
+ }
+ }
+}
+
diff --git a/Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift b/Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift
new file mode 100644
index 00000000..fc7123e6
--- /dev/null
+++ b/Tool/Sources/RAGChatAgent/RAGChatAgentConfiguration.swift
@@ -0,0 +1,119 @@
+import AIModel
+import ChatBasic
+import CodableWrappers
+import Foundation
+import OpenAIService
+import Preferences
+import Keychain
+
+public struct RAGChatAgentConfiguration: Codable {
+ public struct ModelConfiguration: Codable {
+ public var maxTokens: Int
+ public var minimumReplyTokens: Int
+ public var temperature: Double
+ public var systemPrompt: String
+
+ public init(
+ maxTokens: Int,
+ minimumReplyTokens: Int,
+ temperature: Double,
+ systemPrompt: String
+ ) {
+ self.maxTokens = maxTokens
+ self.minimumReplyTokens = minimumReplyTokens
+ self.temperature = temperature
+ self.systemPrompt = systemPrompt
+ }
+ }
+
+ public struct ConversationConfiguration: Codable {
+ public var maxTurns: Int
+ public var isConversationIsolated: Bool
+ public var respondInLanguage: String
+
+ public init(maxTurns: Int, isConversationIsolated: Bool, respondInLanguage: String) {
+ self.maxTurns = maxTurns
+ self.isConversationIsolated = isConversationIsolated
+ self.respondInLanguage = respondInLanguage
+ }
+ }
+
+ public enum ServiceProvider: Codable {
+ case chatModel(id: String)
+ case extensionService(id: String)
+ }
+
+ public var id: String
+ public var name: String
+ public var serviceProvider: ServiceProvider
+ @FallbackDecoding
+ public var capabilityIds: Set
+
+ public var modelConfiguration: ModelConfiguration
+ public var conversationConfiguration: ConversationConfiguration
+ var _otherConfigurations: Data
+
+ public init(
+ id: String,
+ name: String,
+ serviceProvider: ServiceProvider,
+ capabilityIds: Set,
+ modelConfiguration: ModelConfiguration,
+ conversationConfiguration: ConversationConfiguration,
+ otherConfigurations: OtherConfiguration
+ ) throws {
+ self.id = id
+ self.name = name
+ self.serviceProvider = serviceProvider
+ self.capabilityIds = capabilityIds
+ self.modelConfiguration = modelConfiguration
+ self.conversationConfiguration = conversationConfiguration
+ _otherConfigurations = try JSONEncoder().encode(otherConfigurations)
+ }
+
+ public func otherConfigurations(
+ as: Configuration.Type = Configuration.self
+ ) throws -> Configuration {
+ try JSONDecoder().decode(Configuration.self, from: _otherConfigurations)
+ }
+
+ public mutating func setOtherConfigurations(
+ _ otherConfigurations: Configuration
+ ) throws {
+ _otherConfigurations = try JSONEncoder().encode(otherConfigurations)
+ }
+
+ var chatGPTConfiguration: ChatGPTConfiguration? {
+ guard case let .chatModel(id) = serviceProvider else { return nil }
+ return .init(
+ model: {
+ let models = UserDefaults.shared.value(for: \.chatModels)
+ let id = UserDefaults.shared.value(for: \.defaultChatFeatureChatModelId)
+ return models.first { $0.id == id }
+ ?? models.first
+ }(),
+ temperature: modelConfiguration.temperature,
+ stop: [],
+ maxTokens: modelConfiguration.maxTokens,
+ minimumReplyTokens: modelConfiguration.minimumReplyTokens,
+ runFunctionsAutomatically: false,
+ shouldEndTextWindow: { _ in false }
+ )
+ }
+
+ struct ChatGPTConfiguration: OpenAIService.ChatGPTConfiguration {
+ var model: ChatModel?
+ var temperature: Double
+ var stop: [String]
+ var maxTokens: Int
+ var minimumReplyTokens: Int
+ var runFunctionsAutomatically: Bool
+ var shouldEndTextWindow: (String) -> Bool
+
+ var apiKey: String {
+ guard let name = model?.info.apiKeyName else { return "" }
+ return (try? Keychain.apiKey.get(name)) ?? ""
+ }
+ }
+}
+
diff --git a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift
index c38d8373..8969ef6c 100644
--- a/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift
+++ b/Tool/Sources/SharedUIComponents/AsyncCodeBlock.swift
@@ -1,158 +1,48 @@
+import CodeDiff
import DebounceFunction
import Foundation
import Perception
import SwiftUI
-public struct AsyncCodeBlock: View {
- @Perceptible
- class Storage {
- static let queue = DispatchQueue(
- label: "code-block-highlight",
- qos: .userInteractive,
- attributes: .concurrent
- )
-
- var dimmedCharacterCount: Int = 0
- var code: String?
- private var highlightedCode = [NSAttributedString]()
- private var foregroundColor: Color = .primary
- private(set) var commonPrecedingSpaceCount = 0
- var highlightedContent: [NSAttributedString] {
- var highlightedCode = highlightedCode
- if dimmedCharacterCount > commonPrecedingSpaceCount,
- let firstLine = highlightedCode.first
- {
- let dimmedCount = dimmedCharacterCount - commonPrecedingSpaceCount
- let mutable = NSMutableAttributedString(attributedString: firstLine)
- let targetRange = NSRange(
- location: 0,
- length: min(firstLine.length, max(0, dimmedCount))
- )
- mutable.enumerateAttribute(
- .foregroundColor,
- in: NSRange(location: 0, length: firstLine.length)
- ) { value, range, _ in
- guard let color = value as? NSColor else { return }
- let opacity = max(0.1, color.alphaComponent * 0.4)
- if targetRange.upperBound >= range.upperBound {
- mutable.addAttribute(
- .foregroundColor,
- value: color.withAlphaComponent(opacity),
- range: range
- )
- } else {
- let intersection = NSIntersectionRange(targetRange, range)
- guard !(intersection.length == 0) else { return }
- let rangeA = intersection
- mutable.addAttribute(
- .foregroundColor,
- value: color.withAlphaComponent(opacity),
- range: rangeA
- )
-
- let rangeB = NSRange(
- location: intersection.upperBound,
- length: range.upperBound - intersection.upperBound
- )
- mutable.addAttribute(
- .foregroundColor,
- value: color,
- range: rangeB
- )
- }
- }
-
- highlightedCode[0] = mutable
- }
- return highlightedCode
- }
-
- @PerceptionIgnored private var debounceFunction: DebounceFunction?
- @PerceptionIgnored private var highlightTask: Task?
-
- init() {
- debounceFunction = .init(duration: 0.1, block: { view in
- self.highlight(for: view)
- })
- }
-
- func highlight(debounce: Bool, for view: AsyncCodeBlock) {
- if debounce {
- Task { @MainActor in await debounceFunction?(view) }
- } else {
- highlight(for: view)
- }
- }
-
- private func highlight(for view: AsyncCodeBlock) {
- highlightTask?.cancel()
- let code = self.code ?? view.code
- let language = view.language
- let scenario = view.scenario
- let brightMode = view.colorScheme != .dark
- let droppingLeadingSpaces = view.droppingLeadingSpaces
- let font = view.font
- foregroundColor = view.foregroundColor
-
- if highlightedCode.isEmpty {
- let content = CodeHighlighting.convertToCodeLines(
- .init(string: code),
- middleDotColor: brightMode
- ? NSColor.black.withAlphaComponent(0.1)
- : NSColor.white.withAlphaComponent(0.1),
- droppingLeadingSpaces: droppingLeadingSpaces,
- replaceSpacesWithMiddleDots: true
- )
- highlightedCode = content.code
- commonPrecedingSpaceCount = content.commonLeadingSpaceCount
- }
-
- highlightTask = Task {
- let result = await withUnsafeContinuation { continuation in
- Self.queue.async {
- let content = CodeHighlighting.highlighted(
- code: code,
- language: language,
- scenario: scenario,
- brightMode: brightMode,
- droppingLeadingSpaces: droppingLeadingSpaces,
- font: font
- )
- continuation.resume(returning: content)
- }
- }
- try Task.checkCancellation()
- await MainActor.run {
- self.highlightedCode = result.0
- self.commonPrecedingSpaceCount = result.1
- }
- }
- }
- }
-
+public struct AsyncCodeBlock: View { // chat: hid
@State var storage = Storage()
@Environment(\.colorScheme) var colorScheme
+ /// If original code is provided, diff will be generated.
+ let originalCode: String?
+ /// The code to present.
let code: String
+ /// The language of the code.
let language: String
+ /// The index of the first line.
let startLineIndex: Int
+ /// The scenario of the code block.
let scenario: String
+ /// The font of the code block.
let font: NSFont
+ /// The default foreground color of the code block.
let proposedForegroundColor: Color?
- let dimmedCharacterCount: Int
+ /// The ranges to dim in the code.
+ let dimmedCharacterCount: DimmedCharacterCount
+ /// Whether to drop common leading spaces of each line.
let droppingLeadingSpaces: Bool
+ /// Whether to ignore whole line change in diff.
+ let ignoreWholeLineChangeInDiff: Bool
public init(
code: String,
+ originalCode: String? = nil,
language: String,
startLineIndex: Int,
scenario: String,
font: NSFont,
droppingLeadingSpaces: Bool,
proposedForegroundColor: Color?,
- dimmedCharacterCount: Int
+ dimmedCharacterCount: DimmedCharacterCount = .init(prefix: 0, suffix: 0),
+ ignoreWholeLineChangeInDiff: Bool = true
) {
self.code = code
+ self.originalCode = originalCode
self.startLineIndex = startLineIndex
self.language = language
self.scenario = scenario
@@ -160,6 +50,7 @@ public struct AsyncCodeBlock: View {
self.proposedForegroundColor = proposedForegroundColor
self.dimmedCharacterCount = dimmedCharacterCount
self.droppingLeadingSpaces = droppingLeadingSpaces
+ self.ignoreWholeLineChangeInDiff = ignoreWholeLineChangeInDiff
}
var foregroundColor: Color {
@@ -168,8 +59,8 @@ public struct AsyncCodeBlock: View {
public var body: some View {
WithPerceptionTracking {
+ let commonPrecedingSpaceCount = storage.highlightStorage.commonPrecedingSpaceCount
VStack(spacing: 2) {
- let commonPrecedingSpaceCount = storage.commonPrecedingSpaceCount
ForEach(Array(storage.highlightedContent.enumerated()), id: \.0) { item in
let (index, attributedString) = item
HStack(alignment: .firstTextBaseline, spacing: 4) {
@@ -197,52 +88,468 @@ public struct AsyncCodeBlock: View {
.foregroundColor(.white)
.font(.init(font))
.padding(.leading, 4)
- .padding([.trailing, .top, .bottom])
+ .padding(.trailing)
+ .padding(.top, commonPrecedingSpaceCount > 0 ? 16 : 4)
+ .padding(.bottom, 4)
.onAppear {
storage.dimmedCharacterCount = dimmedCharacterCount
- storage.highlight(debounce: false, for: self)
+ storage.ignoreWholeLineChangeInDiff = ignoreWholeLineChangeInDiff
+ storage.highlightStorage.highlight(debounce: false, for: self)
+ storage.diffStorage.diff(for: self)
}
.onChange(of: code) { code in
- storage.code = code // But why do we need this? Time to learn some SwiftUI!
- storage.highlight(debounce: true, for: self)
+ storage.code = code
+ storage.highlightStorage.highlight(debounce: true, for: self)
+ storage.diffStorage.diff(for: self)
+ }
+ .onChange(of: originalCode) { originalCode in
+ storage.originalCode = originalCode
+ storage.diffStorage.diff(for: self)
}
.onChange(of: colorScheme) { _ in
- storage.highlight(debounce: true, for: self)
+ storage.highlightStorage.highlight(debounce: true, for: self)
}
.onChange(of: droppingLeadingSpaces) { _ in
- storage.highlight(debounce: true, for: self)
+ storage.highlightStorage.highlight(debounce: true, for: self)
}
.onChange(of: scenario) { _ in
- storage.highlight(debounce: true, for: self)
+ storage.highlightStorage.highlight(debounce: true, for: self)
}
.onChange(of: language) { _ in
- storage.highlight(debounce: true, for: self)
+ storage.highlightStorage.highlight(debounce: true, for: self)
}
.onChange(of: proposedForegroundColor) { _ in
- storage.highlight(debounce: true, for: self)
+ storage.highlightStorage.highlight(debounce: true, for: self)
}
.onChange(of: dimmedCharacterCount) { value in
storage.dimmedCharacterCount = value
}
+ .onChange(of: ignoreWholeLineChangeInDiff) { value in
+ storage.ignoreWholeLineChangeInDiff = value
+ }
}
}
+}
- static func highlight(
- code: String,
- language: String,
- scenario: String,
- colorScheme: ColorScheme,
- font: NSFont,
- droppingLeadingSpaces: Bool
- ) -> (code: [NSAttributedString], commonLeadingSpaceCount: Int) {
- return CodeHighlighting.highlighted(
- code: code,
- language: language,
- scenario: scenario,
- brightMode: colorScheme != .dark,
- droppingLeadingSpaces: droppingLeadingSpaces,
- font: font
- )
+// MARK: - Storage
+
+extension AsyncCodeBlock {
+ static let queue = DispatchQueue(
+ label: "code-block-highlight",
+ qos: .userInteractive,
+ attributes: .concurrent
+ )
+
+ public struct DimmedCharacterCount: Equatable {
+ public var prefix: Int
+ public var suffix: Int
+ public init(prefix: Int, suffix: Int) {
+ self.prefix = prefix
+ self.suffix = suffix
+ }
+ }
+
+ @Perceptible
+ class Storage {
+ var dimmedCharacterCount: DimmedCharacterCount = .init(prefix: 0, suffix: 0)
+ let diffStorage = DiffStorage()
+ let highlightStorage = HighlightStorage()
+ var ignoreWholeLineChangeInDiff: Bool = true
+
+ var code: String? {
+ get { highlightStorage.code }
+ set {
+ highlightStorage.code = newValue
+ diffStorage.code = newValue
+ }
+ }
+
+ var originalCode: String? {
+ get { diffStorage.originalCode }
+ set { diffStorage.originalCode = newValue }
+ }
+
+ var highlightedContent: [NSAttributedString] {
+ let commonPrecedingSpaceCount = highlightStorage.commonPrecedingSpaceCount
+ let highlightedCode = highlightStorage.highlightedCode
+ .map(NSMutableAttributedString.init(attributedString:))
+
+ Self.dim(
+ highlightedCode,
+ commonPrecedingSpaceCount: commonPrecedingSpaceCount,
+ dimmedCharacterCount: dimmedCharacterCount
+ )
+
+ if let diffResult = diffStorage.diffResult {
+ Self.presentDiff(
+ highlightedCode,
+ commonPrecedingSpaceCount: commonPrecedingSpaceCount,
+ ignoreWholeLineChange: ignoreWholeLineChangeInDiff,
+ diffResult: diffResult
+ )
+ }
+
+ return highlightedCode
+ }
+
+ static func dim(
+ _ highlightedCode: [NSMutableAttributedString],
+ commonPrecedingSpaceCount: Int,
+ dimmedCharacterCount: DimmedCharacterCount
+ ) {
+ func dim(
+ _ line: NSMutableAttributedString,
+ in targetRange: Range,
+ opacity: Double
+ ) {
+ let targetRange = NSRange(targetRange, in: line.string)
+ line.enumerateAttribute(
+ .foregroundColor,
+ in: NSRange(location: 0, length: line.length)
+ ) { value, range, _ in
+ guard let color = value as? NSColor else { return }
+ let opacity = max(0.1, color.alphaComponent * opacity)
+ let intersection = NSIntersectionRange(targetRange, range)
+ guard !(intersection.length == 0) else { return }
+ let rangeA = intersection
+ line.addAttribute(
+ .foregroundColor,
+ value: color.withAlphaComponent(opacity),
+ range: rangeA
+ )
+
+ let rangeB = NSRange(
+ location: intersection.upperBound,
+ length: range.upperBound - intersection.upperBound
+ )
+ line.addAttribute(
+ .foregroundColor,
+ value: color,
+ range: rangeB
+ )
+ }
+ }
+
+ if dimmedCharacterCount.prefix > commonPrecedingSpaceCount,
+ let firstLine = highlightedCode.first
+ {
+ let dimmedCount = dimmedCharacterCount.prefix - commonPrecedingSpaceCount
+ let startIndex = firstLine.string.startIndex
+ let endIndex = firstLine.string.utf16.index(
+ startIndex,
+ offsetBy: min(firstLine.length, max(0, dimmedCount)),
+ limitedBy: firstLine.string.endIndex
+ ) ?? firstLine.string.endIndex
+ if endIndex > startIndex {
+ dim(firstLine, in: startIndex.. mutableString.length {
+ continue
+ }
+ mutableString.addAttributes([
+ .backgroundColor: NSColor.systemGreen.withAlphaComponent(0.2),
+ ], range: range)
+ }
+ }
+ } else if let firstMutableString = highlightedCode.first,
+ let oldLine = diffResult.line(at: 0, in: \.oldSnippet),
+ oldLine.text.count > commonPrecedingSpaceCount
+ {
+ // Only highlight the diffs inside the dimmed area
+ let scopeRange = NSRange(
+ location: 0,
+ length: min(
+ oldLine.text.count - commonPrecedingSpaceCount,
+ firstMutableString.length
+ )
+ )
+ if let line = diffResult.line(at: 0, in: \.newSnippet),
+ case let .mutated(changes) = line.diff, !changes.isEmpty
+ {
+ for change in changes {
+ let offset = change.offset - commonPrecedingSpaceCount
+ let range = NSRange(
+ location: max(0, offset),
+ length: max(0, change.element.count + (offset < 0 ? offset : 0))
+ )
+ guard let limitedRange = limitRange(range, inside: scopeRange)
+ else { continue }
+ firstMutableString.addAttributes([
+ .backgroundColor: NSColor.systemGreen.withAlphaComponent(0.2),
+ ], range: limitedRange)
+ }
+ }
+ }
+
+ let lastLineIndex = highlightedCode.endIndex - 1
+ if lastLineIndex >= 0 {
+ if let line = diffResult.line(at: lastLineIndex, in: \.oldSnippet),
+ case let .mutated(changes) = line.diff,
+ changes.count == 1,
+ let change = changes.last,
+ change.offset + change.element.count == line.text.count
+ {
+ let lastLine = highlightedCode[lastLineIndex]
+ lastLine.append(.init(string: String(change.element), attributes: [
+ .foregroundColor: NSColor.systemRed.withAlphaComponent(0.5),
+ .backgroundColor: NSColor.systemRed.withAlphaComponent(0.2),
+ ]))
+ }
+ }
+ }
+ }
+
+ @Perceptible
+ class DiffStorage {
+ private(set) var diffResult: CodeDiff.SnippetDiff?
+
+ @PerceptionIgnored var originalCode: String?
+ @PerceptionIgnored var code: String?
+ @PerceptionIgnored private var diffTask: Task?
+
+ func diff(for view: AsyncCodeBlock) {
+ performDiff(for: view)
+ }
+
+ private func performDiff(for view: AsyncCodeBlock) {
+ diffTask?.cancel()
+ let code = code ?? view.code
+ guard let originalCode = originalCode ?? view.originalCode else {
+ diffResult = nil
+ return
+ }
+
+ diffTask = Task {
+ let result = await withUnsafeContinuation { continuation in
+ AsyncCodeBlock.queue.async {
+ let result = CodeDiff().diff(snippet: code, from: originalCode)
+ continuation.resume(returning: result)
+ }
+ }
+ try Task.checkCancellation()
+ await MainActor.run {
+ diffResult = result
+ }
+ }
+ }
+ }
+
+ @Perceptible
+ class HighlightStorage {
+ private(set) var highlightedCode = [NSAttributedString]()
+ private(set) var commonPrecedingSpaceCount = 0
+
+ @PerceptionIgnored var code: String?
+ @PerceptionIgnored private var foregroundColor: Color = .primary
+ @PerceptionIgnored private var debounceFunction: DebounceFunction?
+ @PerceptionIgnored private var highlightTask: Task?
+
+ init() {
+ debounceFunction = .init(duration: 0.1, block: { view in
+ self.highlight(for: view)
+ })
+ }
+
+ func highlight(debounce: Bool, for view: AsyncCodeBlock) {
+ if debounce {
+ Task { @MainActor in await debounceFunction?(view) }
+ } else {
+ highlight(for: view)
+ }
+ }
+
+ private func highlight(for view: AsyncCodeBlock) {
+ highlightTask?.cancel()
+ let code = self.code ?? view.code
+ let language = view.language
+ let scenario = view.scenario
+ let brightMode = view.colorScheme != .dark
+ let droppingLeadingSpaces = view.droppingLeadingSpaces
+ foregroundColor = view.foregroundColor
+
+ if highlightedCode.isEmpty {
+ let content = CodeHighlighting.convertToCodeLines(
+ .init(string: code),
+ middleDotColor: brightMode
+ ? NSColor.black.withAlphaComponent(0.1)
+ : NSColor.white.withAlphaComponent(0.1),
+ droppingLeadingSpaces: droppingLeadingSpaces,
+ replaceSpacesWithMiddleDots: true
+ )
+ highlightedCode = content.code
+ commonPrecedingSpaceCount = content.commonLeadingSpaceCount
+ }
+
+ highlightTask = Task {
+ let result = await withUnsafeContinuation { continuation in
+ AsyncCodeBlock.queue.async {
+ let font = view.font
+ let content = CodeHighlighting.highlighted(
+ code: code,
+ language: language,
+ scenario: scenario,
+ brightMode: brightMode,
+ droppingLeadingSpaces: droppingLeadingSpaces,
+ font: font
+ )
+ continuation.resume(returning: content)
+ }
+ }
+ try Task.checkCancellation()
+ await MainActor.run {
+ self.highlightedCode = result.0
+ self.commonPrecedingSpaceCount = result.1
+ }
+ }
+ }
+ }
+
+ static func limitRange(_ nsRange: NSRange, inside another: NSRange) -> NSRange? {
+ let intersection = NSIntersectionRange(nsRange, another)
+ guard intersection.length > 0 else { return nil }
+ return intersection
}
}
+#Preview("Single Line Suggestion") {
+ AsyncCodeBlock(
+ code: " let foo = Bar()",
+ originalCode: " var foo // comment",
+ language: "swift",
+ startLineIndex: 10,
+ scenario: "",
+ font: .monospacedSystemFont(ofSize: 12, weight: .regular),
+ droppingLeadingSpaces: true,
+ proposedForegroundColor: .primary,
+ dimmedCharacterCount: .init(prefix: 11, suffix: 0)
+ )
+ .frame(width: 400, height: 100)
+}
+
+#Preview("Single Line Suggestion / Appending Suffix") {
+ AsyncCodeBlock(
+ code: " let foo = Bar() // comment",
+ originalCode: " var foo // comment",
+ language: "swift",
+ startLineIndex: 10,
+ scenario: "",
+ font: .monospacedSystemFont(ofSize: 12, weight: .regular),
+ droppingLeadingSpaces: true,
+ proposedForegroundColor: .primary,
+ dimmedCharacterCount: .init(prefix: 11, suffix: 11)
+ )
+ .frame(width: 400, height: 100)
+}
+
+#Preview("Multiple Line Suggestion") {
+ AsyncCodeBlock(
+ code: " let foo = Bar()\n print(foo)",
+ originalCode: " var foo // comment\n print(bar)",
+ language: "swift",
+ startLineIndex: 10,
+ scenario: "",
+ font: .monospacedSystemFont(ofSize: 12, weight: .regular),
+ droppingLeadingSpaces: true,
+ proposedForegroundColor: .primary,
+ dimmedCharacterCount: .init(prefix: 11, suffix: 0)
+ )
+ .frame(width: 400, height: 100)
+}
+
+#Preview("Multiple Line Suggestion Including Whole Line Change in Diff") {
+ AsyncCodeBlock(
+ code: "// comment\n let foo = Bar()\n print(bar)\n print(foo)",
+ originalCode: " let foo = Bar()\n",
+ language: "swift",
+ startLineIndex: 10,
+ scenario: "",
+ font: .monospacedSystemFont(ofSize: 12, weight: .regular),
+ droppingLeadingSpaces: true,
+ proposedForegroundColor: .primary,
+ dimmedCharacterCount: .init(prefix: 11, suffix: 0),
+ ignoreWholeLineChangeInDiff: false
+ )
+ .frame(width: 400, height: 100)
+}
+
+#Preview("Updating Content") {
+ struct UpdateContent: View {
+ @State var index = 0
+ struct Case {
+ let code: String
+ let originalCode: String
+ }
+
+ let cases: [Case] = [
+ .init(code: "foo(123)\nprint(foo)", originalCode: "bar(234)\nprint(bar)"),
+ .init(code: "bar(456)", originalCode: "baz(567)"),
+ ]
+
+ var body: some View {
+ VStack {
+ Button("Update") {
+ index = (index + 1) % cases.count
+ }
+ AsyncCodeBlock(
+ code: cases[index].code,
+ originalCode: cases[index].originalCode,
+ language: "swift",
+ startLineIndex: 10,
+ scenario: "",
+ font: .monospacedSystemFont(ofSize: 12, weight: .regular),
+ droppingLeadingSpaces: true,
+ proposedForegroundColor: .primary,
+ dimmedCharacterCount: .init(prefix: 0, suffix: 0)
+ )
+ }
+ }
+ }
+
+ return UpdateContent()
+ .frame(width: 400, height: 200)
+}
+
diff --git a/Tool/Sources/SharedUIComponents/CodeBlock.swift b/Tool/Sources/SharedUIComponents/CodeBlock.swift
index 1208c14b..86cb4fde 100644
--- a/Tool/Sources/SharedUIComponents/CodeBlock.swift
+++ b/Tool/Sources/SharedUIComponents/CodeBlock.swift
@@ -84,7 +84,9 @@ public struct CodeBlock: View {
.foregroundColor(.white)
.font(.init(font))
.padding(.leading, 4)
- .padding([.trailing, .top, .bottom])
+ .padding(.trailing)
+ .padding(.top, commonPrecedingSpaceCount > 0 ? 16 : 4)
+ .padding(.bottom, 4)
}
static func highlight(
diff --git a/Tool/Sources/SharedUIComponents/ModifierFlagsMonitor.swift b/Tool/Sources/SharedUIComponents/ModifierFlagsMonitor.swift
new file mode 100644
index 00000000..1ae82b7f
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/ModifierFlagsMonitor.swift
@@ -0,0 +1,55 @@
+import Cocoa
+import Foundation
+import SwiftUI
+
+public extension View {
+ func modifierFlagsMonitor() -> some View {
+ ModifierFlagsMonitorWrapper { self }
+ }
+}
+
+public extension EnvironmentValues {
+ var modifierFlags: NSEvent.ModifierFlags {
+ get { self[ModifierFlagsEnvironmentKey.self] }
+ set { self[ModifierFlagsEnvironmentKey.self] = newValue }
+ }
+}
+
+final class ModifierFlagsMonitor {
+ private var monitor: Any?
+
+ deinit { stop() }
+
+ func start(binding: Binding) {
+ guard monitor == nil else { return }
+ monitor = NSEvent.addLocalMonitorForEvents(matching: [.flagsChanged]) { event in
+ binding.wrappedValue = event.modifierFlags
+ return event
+ }
+ }
+
+ func stop() {
+ if let monitor {
+ NSEvent.removeMonitor(monitor)
+ self.monitor = nil
+ }
+ }
+}
+
+struct ModifierFlagsMonitorWrapper: View {
+ @ViewBuilder let content: () -> Content
+ @State private var modifierFlags: NSEvent.ModifierFlags = []
+ @State private var eventMonitor = ModifierFlagsMonitor()
+
+ var body: some View {
+ content()
+ .environment(\.modifierFlags, modifierFlags)
+ .onAppear { eventMonitor.start(binding: $modifierFlags) }
+ .onDisappear { eventMonitor.stop() }
+ }
+}
+
+struct ModifierFlagsEnvironmentKey: EnvironmentKey {
+ static let defaultValue: NSEvent.ModifierFlags = []
+}
+
diff --git a/Core/Sources/HostApp/SharedComponents/SubSection.swift b/Tool/Sources/SharedUIComponents/SubSection.swift
similarity index 82%
rename from Core/Sources/HostApp/SharedComponents/SubSection.swift
rename to Tool/Sources/SharedUIComponents/SubSection.swift
index b294e3e4..4fc78274 100644
--- a/Core/Sources/HostApp/SharedComponents/SubSection.swift
+++ b/Tool/Sources/SharedUIComponents/SubSection.swift
@@ -1,17 +1,17 @@
import SwiftUI
-struct SubSection: View {
- let title: Title
- let description: Description
- @ViewBuilder let content: () -> Content
+public struct SubSection: View {
+ public let title: Title
+ public let description: Description
+ @ViewBuilder public let content: () -> Content
- init(title: Title, description: Description, @ViewBuilder content: @escaping () -> Content) {
+ public init(title: Title, description: Description, @ViewBuilder content: @escaping () -> Content) {
self.title = title
self.description = description
self.content = content
}
- var body: some View {
+ public var body: some View {
VStack(alignment: .leading) {
if !(title is EmptyView && description is EmptyView) {
VStack(alignment: .leading, spacing: 8) {
@@ -43,31 +43,31 @@ struct SubSection: View {
}
}
-extension SubSection where Description == Text {
+public extension SubSection where Description == Text {
init(title: Title, description: String, @ViewBuilder content: @escaping () -> Content) {
self.init(title: title, description: Text(description), content: content)
}
}
-extension SubSection where Description == EmptyView {
+public extension SubSection where Description == EmptyView {
init(title: Title, @ViewBuilder content: @escaping () -> Content) {
self.init(title: title, description: EmptyView(), content: content)
}
}
-extension SubSection where Title == EmptyView {
+public extension SubSection where Title == EmptyView {
init(description: Description, @ViewBuilder content: @escaping () -> Content) {
self.init(title: EmptyView(), description: description, content: content)
}
}
-extension SubSection where Title == EmptyView, Description == EmptyView {
+public extension SubSection where Title == EmptyView, Description == EmptyView {
init(@ViewBuilder content: @escaping () -> Content) {
self.init(title: EmptyView(), description: EmptyView(), content: content)
}
}
-extension SubSection where Title == EmptyView, Description == Text {
+public extension SubSection where Title == EmptyView, Description == Text {
init(description: String, @ViewBuilder content: @escaping () -> Content) {
self.init(title: EmptyView(), description: description, content: content)
}
diff --git a/Tool/Sources/SharedUIComponents/TabContainer.swift b/Tool/Sources/SharedUIComponents/TabContainer.swift
new file mode 100644
index 00000000..06611861
--- /dev/null
+++ b/Tool/Sources/SharedUIComponents/TabContainer.swift
@@ -0,0 +1,70 @@
+import Dependencies
+import Foundation
+import SwiftUI
+
+public final class ExternalTabContainer {
+ public static var tabContainers = [String: ExternalTabContainer]()
+
+ public struct TabItem: Identifiable {
+ public var id: String
+ public var title: String
+ public var image: String
+ public let viewBuilder: () -> AnyView
+
+ public init(
+ id: String,
+ title: String,
+ image: String = "",
+ @ViewBuilder viewBuilder: @escaping () -> V
+ ) {
+ self.id = id
+ self.title = title
+ self.image = image
+ self.viewBuilder = { AnyView(viewBuilder()) }
+ }
+ }
+
+ public var tabs: [TabItem] = []
+ public init() { tabs = [] }
+
+ public static func tabContainer(for id: String) -> ExternalTabContainer {
+ if let tabContainer = tabContainers[id] {
+ return tabContainer
+ }
+ let tabContainer = ExternalTabContainer()
+ tabContainers[id] = tabContainer
+ return tabContainer
+ }
+
+ @ViewBuilder
+ public func tabView(for id: String) -> some View {
+ if let tab = tabs.first(where: { $0.id == id }) {
+ tab.viewBuilder()
+ }
+ }
+
+ public func registerTab(
+ id: String,
+ title: String,
+ image: String = "",
+ @ViewBuilder viewBuilder: @escaping () -> V
+ ) {
+ tabs.append(TabItem(id: id, title: title, image: image, viewBuilder: viewBuilder))
+ }
+
+ public static func registerTab(
+ for tabContainerId: String,
+ id: String,
+ title: String,
+ image: String = "",
+ @ViewBuilder viewBuilder: @escaping () -> V
+ ) {
+ tabContainer(for: tabContainerId).registerTab(
+ id: id,
+ title: title,
+ image: image,
+ viewBuilder: viewBuilder
+ )
+ }
+}
+
diff --git a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift
index bd124fc1..c9967a49 100644
--- a/Tool/Sources/SuggestionBasic/CodeSuggestion.swift
+++ b/Tool/Sources/SuggestionBasic/CodeSuggestion.swift
@@ -1,25 +1,49 @@
-import Foundation
import CodableWrappers
+import Foundation
public struct CodeSuggestion: Codable, Equatable {
+ public struct Description: Codable, Equatable {
+ public enum Kind: Codable, Equatable {
+ case warning
+ case action
+ }
+
+ public var kind: Kind
+ public var content: String
+
+ public init(kind: Kind, content: String) {
+ self.kind = kind
+ self.content = content
+ }
+ }
+
public init(
id: String,
text: String,
position: CursorPosition,
- range: CursorRange
+ range: CursorRange,
+ replacingLines: [String] = [],
+ descriptions: [Description] = [],
+ middlewareComments: [String] = [],
+ metadata: [String: String] = [:]
) {
self.text = text
self.position = position
self.id = id
self.range = range
- middlewareComments = []
+ self.replacingLines = replacingLines
+ self.descriptions = descriptions
+ self.middlewareComments = middlewareComments
+ self.metadata = metadata
}
public static func == (lhs: CodeSuggestion, rhs: CodeSuggestion) -> Bool {
- return lhs.text == rhs.text
- && lhs.position == rhs.position
- && lhs.id == rhs.id
- && lhs.range == rhs.range
+ return lhs.text == rhs.text
+ && lhs.position == rhs.position
+ && lhs.id == rhs.id
+ && lhs.range == rhs.range
+ && lhs.descriptions == rhs.descriptions
+ && lhs.middlewareComments == rhs.middlewareComments
}
/// The new code to be inserted and the original code on the first line.
@@ -30,7 +54,13 @@ public struct CodeSuggestion: Codable, Equatable {
public var id: String
/// The range of the original code that should be replaced.
public var range: CursorRange
+ /// Descriptions about this code suggestion
+ @FallbackDecoding public var replacingLines: [String]
+ /// Descriptions about this code suggestion
+ @FallbackDecoding public var descriptions: [Description]
/// A place to store comments inserted by middleware for debugging use.
@FallbackDecoding public var middlewareComments: [String]
+ /// A place to store extra data.
+ @FallbackDecoding public var metadata: [String: String]
}
diff --git a/Core/Sources/SuggestionInjector/SuggestionInjector.swift b/Tool/Sources/SuggestionInjector/SuggestionInjector.swift
similarity index 61%
rename from Core/Sources/SuggestionInjector/SuggestionInjector.swift
rename to Tool/Sources/SuggestionInjector/SuggestionInjector.swift
index a7fcedaf..00b817d0 100644
--- a/Core/Sources/SuggestionInjector/SuggestionInjector.swift
+++ b/Tool/Sources/SuggestionInjector/SuggestionInjector.swift
@@ -10,7 +10,7 @@ public struct SuggestionInjector {
public struct ExtraInfo {
public var didChangeContent = false
public var didChangeCursorPosition = false
- public var suggestionRange: ClosedRange?
+ public var modificationRanges: [String: CursorRange] = [:]
public var modifications: [Modification] = []
public init() {}
}
@@ -23,7 +23,6 @@ public struct SuggestionInjector {
) {
extraInfo.didChangeContent = true
extraInfo.didChangeCursorPosition = true
- extraInfo.suggestionRange = nil
let start = completion.range.start
let end = completion.range.end
let suggestionContent = completion.text
@@ -34,7 +33,7 @@ public struct SuggestionInjector {
}
let firstRemovedLine = content[safe: start.line]
- let lastRemovedLine = content[safe: end.line]
+ let lastRemovedLine = completion.replacingLines[safe: max(0, end.line - start.line)]
let startLine = max(0, start.line)
let endLine = max(start.line, min(end.line, content.endIndex - 1))
if startLine < content.endIndex {
@@ -72,7 +71,7 @@ public struct SuggestionInjector {
let recoveredSuffixLength = recoverSuffixIfNeeded(
endOfReplacedContent: end,
toBeInserted: &toBeInserted,
- lastRemovedLine: lastRemovedLine,
+ originalLastRemovedLine: lastRemovedLine,
lineEnding: lineEnding
)
@@ -85,57 +84,109 @@ public struct SuggestionInjector {
line: startLine + toBeInserted.count - 1,
character: max(0, cursorCol)
)
+ extraInfo.modificationRanges[completion.id] = .init(start: start, end: cursorPosition)
+ }
+
+ public func acceptSuggestions(
+ intoContentWithoutSuggestion content: inout [String],
+ cursorPosition: inout CursorPosition,
+ completions: [CodeSuggestion],
+ extraInfo: inout ExtraInfo
+ ) {
+ let sortedCompletions = completions.sorted {
+ if $0.range.start.line < $1.range.start.line {
+ true
+ } else if $0.range.start.line == $1.range.start.line {
+ $0.range.start.character < $1.range.start.character
+ } else {
+ false
+ }
+ }
+
+ for var completion in sortedCompletions {
+ let lineCountChange: Int = {
+ var accumulation = 0
+ let endIndex = completion.range.start.line
+ for modification in extraInfo.modifications {
+ switch modification {
+ case let .deleted(range):
+ if range.lowerBound <= endIndex {
+ accumulation -= range.count
+ if range.upperBound >= endIndex {
+ accumulation += range.upperBound - endIndex
+ }
+ }
+ case let .inserted(index, lines):
+ if index <= endIndex {
+ accumulation += lines.count
+ }
+ }
+ }
+ return accumulation
+ }()
+
+ if lineCountChange != 0 {
+ completion.position = CursorPosition(
+ line: completion.position.line + lineCountChange,
+ character: completion.position.character
+ )
+ completion.range = CursorRange(
+ start: CursorPosition(
+ line: completion.range.start.line + lineCountChange,
+ character: completion.range.start.character
+ ),
+ end: CursorPosition(
+ line: completion.range.end.line + lineCountChange,
+ character: completion.range.end.character
+ )
+ )
+ }
+
+ completion.replacingLines = {
+ let start = completion.range.start.line
+ let end = completion.range.end.line
+ if start >= content.endIndex {
+ return []
+ }
+ if end < content.endIndex {
+ return Array(content[start...end])
+ }
+ return Array(content[start...])
+ }()
+
+ // Accept the suggestion
+ acceptSuggestion(
+ intoContentWithoutSuggestion: &content,
+ cursorPosition: &cursorPosition,
+ completion: completion,
+ extraInfo: &extraInfo
+ )
+ }
}
func recoverSuffixIfNeeded(
endOfReplacedContent end: CursorPosition,
toBeInserted: inout [String],
- lastRemovedLine: String?,
+ originalLastRemovedLine: String?,
lineEnding: String
) -> Int {
// If there is no line removed, there is no need to recover anything.
- guard let lastRemovedLine, !lastRemovedLine.isEmptyOrNewLine else { return 0 }
+ guard let lastRemovedLine = originalLastRemovedLine,
+ !lastRemovedLine.isEmptyOrNewLine else { return 0 }
let lastRemovedLineCleaned = lastRemovedLine.droppedLineBreak()
- // If the replaced range covers the whole line, return immediately.
- guard end.character >= 0, end.character - 1 < lastRemovedLineCleaned.utf16.count
- else { return 0 }
-
- // if we are not inserting anything, return immediately.
- guard !toBeInserted.isEmpty,
- let first = toBeInserted.first?.droppedLineBreak(), !first.isEmpty,
- let last = toBeInserted.last?.droppedLineBreak(), !last.isEmpty
- else { return 0 }
-
- // case 1: user keeps typing as the suggestion suggests.
-
- if first.hasPrefix(lastRemovedLineCleaned) {
- return 0
- }
-
- // case 2: user also typed the suffix of the suggestion (or auto-completed by Xcode)
-
// locate the split index, the prefix of which matches the suggestion prefix.
- var splitIndex: String.Index?
-
- for offset in end.character..`
diff --git a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift
index 345dc1ef..227aac22 100644
--- a/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift
+++ b/Tool/Sources/SuggestionProvider/PostProcessingSuggestionServiceMiddleware.swift
@@ -17,16 +17,24 @@ public struct PostProcessingSuggestionServiceMiddleware: SuggestionServiceMiddle
Self.removeTrailingWhitespacesAndNewlines(&suggestion)
Self.removeRedundantClosingParenthesis(&suggestion, lines: request.lines)
if !Self.checkIfSuggestionHasNoEffect(suggestion, request: request) { return nil }
+ Self.injectReplacingLines(&suggestion, request: request)
return suggestion
}
}
static func removeTrailingWhitespacesAndNewlines(_ suggestion: inout CodeSuggestion) {
- var text = suggestion.text[...]
- while let last = text.last, last.isNewline || last.isWhitespace {
- text = text.dropLast(1)
- }
- suggestion.text = String(text)
+ suggestion.text = suggestion.text.removedTrailingWhitespacesAndNewlines()
+ }
+
+ static func injectReplacingLines(
+ _ suggestion: inout CodeSuggestion,
+ request: SuggestionRequest
+ ) {
+ guard !request.lines.isEmpty else { return }
+ let range = suggestion.range
+ let lowerBound = max(0, range.start.line)
+ let upperBound = max(lowerBound, min(request.lines.count - 1, range.end.line))
+ suggestion.replacingLines = Array(request.lines[lowerBound...upperBound])
}
/// Remove the parenthesis in the last line of the suggestion if
@@ -64,6 +72,7 @@ public struct PostProcessingSuggestionServiceMiddleware: SuggestionServiceMiddle
} else {
suggestion.text = ""
}
+ suggestion.middlewareComments.append("Removed redundant closing parenthesis.")
}
}
diff --git a/Tool/Sources/SuggestionProvider/String+Extension.swift b/Tool/Sources/SuggestionProvider/String+Extension.swift
new file mode 100644
index 00000000..8842c775
--- /dev/null
+++ b/Tool/Sources/SuggestionProvider/String+Extension.swift
@@ -0,0 +1,33 @@
+import Foundation
+
+public extension String {
+ func removedTrailingWhitespacesAndNewlines() -> String {
+ var text = self[...]
+ while let last = text.last, last.isNewline || last.isWhitespace {
+ text = text.dropLast(1)
+ }
+ return String(text)
+ }
+
+ func removedTrailingCharacters(in set: CharacterSet) -> String {
+ var text = self[...]
+ while let last = text.last, set.containsUnicodeScalars(of: last) {
+ text = text.dropLast(1)
+ }
+ return String(text)
+ }
+
+ func removeLeadingCharacters(in set: CharacterSet) -> String {
+ var text = self[...]
+ while let first = text.first, set.containsUnicodeScalars(of: first) {
+ text = text.dropFirst()
+ }
+ return String(text)
+ }
+}
+
+extension CharacterSet {
+ func containsUnicodeScalars(of character: Character) -> Bool {
+ return character.unicodeScalars.allSatisfy(contains(_:))
+ }
+}
diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift
new file mode 100644
index 00000000..e89fe938
--- /dev/null
+++ b/Tool/Sources/SuggestionProvider/SuggestionServiceEventHandler.swift
@@ -0,0 +1,27 @@
+import CopilotForXcodeKit
+import Foundation
+import SuggestionBasic
+
+public protocol SuggestionServiceEventHandler {
+ func didAccept(_ suggestion: SuggestionBasic.CodeSuggestion, workspaceInfo: WorkspaceInfo)
+ func didReject(_ suggestions: [SuggestionBasic.CodeSuggestion], workspaceInfo: WorkspaceInfo)
+}
+
+public enum SuggestionServiceEventHandlerContainer {
+ static var builtinHandlers: [SuggestionServiceEventHandler] = []
+
+ static var customHandlers: [SuggestionServiceEventHandler] = []
+
+ public static var handlers: [SuggestionServiceEventHandler] {
+ builtinHandlers + customHandlers
+ }
+
+ public static func addHandler(_ handler: SuggestionServiceEventHandler) {
+ customHandlers.append(handler)
+ }
+
+ public static func addHandlers(_ handlers: [SuggestionServiceEventHandler]) {
+ customHandlers.append(contentsOf: handlers)
+ }
+}
+
diff --git a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift
index dcbfba5e..f26492c1 100644
--- a/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift
+++ b/Tool/Sources/SuggestionProvider/SuggestionServiceMiddleware.swift
@@ -17,15 +17,29 @@ public enum SuggestionServiceMiddlewareContainer {
DisabledLanguageSuggestionServiceMiddleware(),
PostProcessingSuggestionServiceMiddleware()
]
+
+ static var leadingMiddlewares: [SuggestionServiceMiddleware] = []
- static var customMiddlewares: [SuggestionServiceMiddleware] = []
+ static var trailingMiddlewares: [SuggestionServiceMiddleware] = []
public static var middlewares: [SuggestionServiceMiddleware] {
- builtInMiddlewares + customMiddlewares
+ leadingMiddlewares + builtInMiddlewares + trailingMiddlewares
}
public static func addMiddleware(_ middleware: SuggestionServiceMiddleware) {
- customMiddlewares.append(middleware)
+ trailingMiddlewares.append(middleware)
+ }
+
+ public static func addMiddlewares(_ middlewares: [SuggestionServiceMiddleware]) {
+ trailingMiddlewares.append(contentsOf: middlewares)
+ }
+
+ public static func addLeadingMiddleware(_ middleware: SuggestionServiceMiddleware) {
+ leadingMiddlewares.append(middleware)
+ }
+
+ public static func addLeadingMiddlewares(_ middlewares: [SuggestionServiceMiddleware]) {
+ leadingMiddlewares.append(contentsOf: middlewares)
}
}
diff --git a/Tool/Sources/Toast/Toast.swift b/Tool/Sources/Toast/Toast.swift
index 2abcca9a..3202ee49 100644
--- a/Tool/Sources/Toast/Toast.swift
+++ b/Tool/Sources/Toast/Toast.swift
@@ -46,15 +46,36 @@ public extension DependencyValues {
public class ToastController: ObservableObject {
public struct Message: Identifiable, Equatable {
+ public struct MessageButton: Equatable {
+ public static func == (lhs: Self, rhs: Self) -> Bool {
+ lhs.label == rhs.label
+ }
+
+ public var label: Text
+ public var action: () -> Void
+ public init(label: Text, action: @escaping () -> Void) {
+ self.label = label
+ self.action = action
+ }
+ }
+
public var namespace: String?
public var id: UUID
public var type: ToastType
public var content: Text
- public init(id: UUID, type: ToastType, namespace: String? = nil, content: Text) {
+ public var buttons: [MessageButton]
+ public init(
+ id: UUID,
+ type: ToastType,
+ namespace: String? = nil,
+ content: Text,
+ buttons: [MessageButton] = []
+ ) {
self.namespace = namespace
self.id = id
self.type = type
self.content = content
+ self.buttons = buttons
}
}
@@ -64,16 +85,35 @@ public class ToastController: ObservableObject {
self.messages = messages
}
- public func toast(content: String, type: ToastType, namespace: String? = nil) {
+ public func toast(
+ content: String,
+ type: ToastType,
+ namespace: String? = nil,
+ buttons: [Message.MessageButton] = [],
+ duration: TimeInterval = 4
+ ) {
let id = UUID()
- let message = Message(id: id, type: type, namespace: namespace, content: Text(content))
+ let message = Message(
+ id: id,
+ type: type,
+ namespace: namespace,
+ content: Text(content),
+ buttons: buttons.map { b in
+ Message.MessageButton(label: b.label, action: { [weak self] in
+ b.action()
+ withAnimation(.easeInOut(duration: 0.2)) {
+ self?.messages.removeAll { $0.id == id }
+ }
+ })
+ }
+ )
Task { @MainActor in
withAnimation(.easeInOut(duration: 0.2)) {
messages.append(message)
messages = messages.suffix(3)
}
- try await Task.sleep(nanoseconds: 4_000_000_000)
+ try await Task.sleep(nanoseconds: UInt64(duration * 1_000_000_000))
withAnimation(.easeInOut(duration: 0.2)) {
messages.removeAll { $0.id == id }
}
@@ -84,7 +124,7 @@ public class ToastController: ObservableObject {
@Reducer
public struct Toast {
public typealias Message = ToastController.Message
-
+
@ObservableState
public struct State: Equatable {
var isObservingToastController = false
diff --git a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift
index ec35158f..25afb616 100644
--- a/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift
+++ b/Tool/Sources/WorkspaceSuggestionService/Filespace+SuggestionService.swift
@@ -1,24 +1,52 @@
import Foundation
import SuggestionBasic
+import SuggestionInjector
import Workspace
+/// The moment when a suggestion is generated.
public struct FilespaceSuggestionSnapshot: Equatable {
- #warning("TODO: Can we remove it?")
- public var linesHash: Int
+ public var editingLine: String
public var cursorPosition: CursorPosition
+ public var editingLinePrefix: String
+ public var editingLineSuffix: String
+
+ public static func == (
+ lhs: FilespaceSuggestionSnapshot,
+ rhs: FilespaceSuggestionSnapshot
+ ) -> Bool {
+ lhs.editingLine == rhs.editingLine
+ && lhs.cursorPosition == rhs.cursorPosition
+ }
- public init(linesHash: Int, cursorPosition: CursorPosition) {
- self.linesHash = linesHash
+ public init(lines: [String], cursorPosition: CursorPosition) {
self.cursorPosition = cursorPosition
+ editingLine = if cursorPosition.line >= 0 && cursorPosition.line < lines.count {
+ lines[cursorPosition.line]
+ } else {
+ ""
+ }
+ let col = cursorPosition.character
+ let view = editingLine.utf16
+ editingLinePrefix = if col >= 0 {
+ String(view.prefix(col)) ?? ""
+ } else {
+ ""
+ }
+ editingLineSuffix = if col >= 0, col < editingLine.utf16.count {
+ String(view[view.index(view.startIndex, offsetBy: col)...]) ?? ""
+ } else {
+ ""
+ }
}
}
public struct FilespaceSuggestionSnapshotKey: FilespacePropertyKey {
public static func createDefaultValue()
- -> FilespaceSuggestionSnapshot { .init(linesHash: -1, cursorPosition: .outOfScope) }
+ -> FilespaceSuggestionSnapshot { .init(lines: [], cursorPosition: .outOfScope) }
}
public extension FilespacePropertyValues {
+ /// The state of the file when a suggestion is generated.
@WorkspaceActor
var suggestionSourceSnapshot: FilespaceSuggestionSnapshot {
get { self[FilespaceSuggestionSnapshotKey.self] }
@@ -29,116 +57,186 @@ public extension FilespacePropertyValues {
public extension Filespace {
@WorkspaceActor
func resetSnapshot() {
- // swiftformat:disable redundantSelf
- self.suggestionSourceSnapshot = FilespaceSuggestionSnapshotKey.createDefaultValue()
- // swiftformat:enable all
+ self[keyPath: \.suggestionSourceSnapshot] = FilespaceSuggestionSnapshotKey
+ .createDefaultValue()
}
/// Validate the suggestion is still valid.
/// - Parameters:
/// - lines: lines of the file
/// - cursorPosition: cursor position
+ /// - alwaysTrueIfCursorNotMoved: for unit tests
/// - Returns: `true` if the suggestion is still valid
@WorkspaceActor
- func validateSuggestions(lines: [String], cursorPosition: CursorPosition) -> Bool {
+ func validateSuggestions(
+ lines: [String],
+ cursorPosition: CursorPosition,
+ alwaysTrueIfCursorNotMoved: Bool = true
+ ) -> Bool {
guard let presentingSuggestion else { return false }
-
- // cursor has moved to another line
- if cursorPosition.line != presentingSuggestion.position.line {
+ let snapshot = self[keyPath: \.suggestionSourceSnapshot]
+ if snapshot.cursorPosition == .outOfScope { return false }
+
+ guard Self.validateSuggestion(
+ presentingSuggestion,
+ snapshot: snapshot,
+ lines: lines,
+ cursorPosition: cursorPosition,
+ alwaysTrueIfCursorNotMoved: alwaysTrueIfCursorNotMoved
+ ) else {
reset()
resetSnapshot()
return false
}
+ return true
+ }
+}
+
+extension Filespace {
+ static func validateSuggestion(
+ _ suggestion: CodeSuggestion,
+ snapshot: FilespaceSuggestionSnapshot,
+ lines: [String],
+ cursorPosition: CursorPosition,
+ // For test
+ alwaysTrueIfCursorNotMoved: Bool = true
+ ) -> Bool {
+ // cursor is not even moved during the generation.
+ if alwaysTrueIfCursorNotMoved, cursorPosition == suggestion.position { return true }
+
+ // cursor has moved to another line
+ if cursorPosition.line != suggestion.position.line { return false }
+
// the cursor position is valid
- guard cursorPosition.line >= 0, cursorPosition.line < lines.count else {
- reset()
- resetSnapshot()
+ guard cursorPosition.line >= 0, cursorPosition.line < lines.count else { return false }
+
+ let editingLine = lines[cursorPosition.line].dropLast(1) // dropping line ending
+ let suggestionLines = suggestion.text.breakLines(appendLineBreakToLastLine: true)
+
+ if Self.validateThatIsNotTypingSuggestion(
+ suggestion,
+ snapshot: snapshot,
+ lines: lines,
+ suggestionLines: suggestionLines,
+ cursorPosition: cursorPosition
+ ) {
return false
}
- let editingLine = lines[cursorPosition.line].dropLast(1) // dropping line ending
- let suggestionLines = presentingSuggestion.text.split(whereSeparator: \.isNewline)
- let suggestionFirstLine = suggestionLines.first ?? ""
+ // if the line will not change after accepting the suggestion
+ if Self.validateThatSuggestionMakeNoDifferent(
+ suggestion,
+ lines: lines,
+ suggestionLines: suggestionLines
+ ) {
+ return false
+ }
- /// For example:
- /// ```
- /// ABCD012 // typed text
- /// ^
- /// 0123456 // suggestion range 4-11, generated after `ABCD`
- /// ```
- /// The suggestion should contain `012`, aka, the suggestion that is typed.
- ///
- /// Another case is that the suggestion may contain the whole line.
- /// /// ```
- /// ABCD012 // typed text
- /// ----^
- /// ABCD0123456 // suggestion range 0-11, generated after `ABCD`
- /// The suggestion should contain `ABCD012`, aka, the suggestion that is typed.
- /// ```
- let typedSuggestion = {
- assert(
- presentingSuggestion.range.start.character >= 0,
- "Generating suggestion with invalid range"
- )
-
- let utf16View = editingLine.utf16
-
- let startIndex = utf16View.index(
- utf16View.startIndex,
- offsetBy: max(0, presentingSuggestion.range.start.character),
- limitedBy: utf16View.endIndex
- ) ?? utf16View.startIndex
-
- let endIndex = utf16View.index(
- utf16View.startIndex,
- offsetBy: cursorPosition.character,
- limitedBy: utf16View.endIndex
- ) ?? utf16View.endIndex
-
- if endIndex > startIndex {
- return String(editingLine[startIndex..= suggestionFirstLine.utf16.count + presentingSuggestion.range.start.character
- {
- reset()
- resetSnapshot()
- return false
- }
+ return true
+ }
+
+ static func validateThatIsNotTypingSuggestion(
+ _ suggestion: CodeSuggestion,
+ snapshot: FilespaceSuggestionSnapshot,
+ lines: [String],
+ suggestionLines: [String],
+ cursorPosition: CursorPosition
+ ) -> Bool {
+ let lineIndex = suggestion.range.start.line
+ let typeStart = suggestion.position.character
+ let cursorColumn = cursorPosition.character
+ let suggestionStart = max(
+ 0,
+ suggestion.position.character - suggestion.range.start.character
+ )
+ func contentBeforeCursor(
+ _ string: String,
+ start: Int
+ ) -> ArraySlice {
+ if start >= cursorColumn { return [] }
+ let elements = Array(string.utf16)
+ guard start >= 0, start < elements.endIndex else { return [] }
+ let endIndex = min(elements.endIndex, cursorColumn)
+ return elements[start.. 0,
- !suggestionFirstLine.hasPrefix(typedSuggestion)
- {
- reset()
- resetSnapshot()
+ guard lineIndex >= 0, lineIndex < lines.endIndex else { return false }
+ let editingLine = lines[lineIndex]
+ let suggestionFirstLine = suggestionLines.first ?? ""
+
+ let typed = contentBeforeCursor(editingLine, start: typeStart)
+ let expectedTyped = contentBeforeCursor(suggestionFirstLine, start: suggestionStart)
+ return typed != expectedTyped
+ }
+
+ static func validateThatSuggestionMakeNoDifferent(
+ _ suggestion: CodeSuggestion,
+ lines: [String],
+ suggestionLines: [String]
+ ) -> Bool {
+ var editingRange = suggestion.range
+ let startLine = max(0, editingRange.start.line)
+ let endLine = max(startLine, min(editingRange.end.line, lines.count - 1))
+
+ // The editing range is out of the file
+ if startLine < 0 || endLine >= lines.count {
return false
}
- // finished typing the whole suggestion when the suggestion has only one line
- if typedSuggestion.hasPrefix(suggestionFirstLine), suggestionLines.count <= 1 {
- reset()
- resetSnapshot()
+ // The suggestion is apparently longer than the editing range
+ if endLine - startLine + 1 != suggestionLines.count {
return false
}
- // undo to a state before the suggestion was generated
- if editingLine.utf16.count < presentingSuggestion.position.character {
- reset()
- resetSnapshot()
- return false
+ let originalEditingLines = Array(lines[startLine...endLine])
+ var editingLines = originalEditingLines
+ editingRange.end = .init(
+ line: editingRange.end.line - editingRange.start.line,
+ character: editingRange.end.character
+ )
+ editingRange.start = .init(line: 0, character: editingRange.start.character)
+ var cursorPosition = CursorPosition(
+ line: suggestion.position.line - startLine,
+ character: suggestion.position.character
+ )
+ let pseudoSuggestion = CodeSuggestion(
+ id: "",
+ text: suggestion.text,
+ position: cursorPosition,
+ range: editingRange
+ )
+ var extraInfo = SuggestionInjector.ExtraInfo()
+ let injector = SuggestionInjector()
+ injector.acceptSuggestion(
+ intoContentWithoutSuggestion: &editingLines,
+ cursorPosition: &cursorPosition,
+ completion: pseudoSuggestion,
+ extraInfo: &extraInfo
+ )
+
+ // We want that finish typing a partial suggestion should also make no difference.
+ if let lastOriginalLine = originalEditingLines.last,
+ cursorPosition.character < lastOriginalLine.utf16.count,
+ // But we also want to separate this case from the case that the suggestion is
+ // shortening the last line. Which does make a difference.
+ suggestion.range.end.character < lastOriginalLine.utf16.count - 1 // for line ending
+ {
+ let editingLinesPrefix = editingLines.dropLast()
+ let originalEditingLinesPrefix = originalEditingLines.dropLast()
+ if editingLinesPrefix != originalEditingLinesPrefix {
+ return false
+ }
+ let lastEditingLine = editingLines.last ?? "\n"
+ return lastOriginalLine.hasPrefix(lastEditingLine.dropLast(1)) // for line ending
}
- return true
+ return editingLines == originalEditingLines
}
}
diff --git a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
index 2fbaddd3..a6f22b2a 100644
--- a/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
+++ b/Tool/Sources/WorkspaceSuggestionService/Workspace+SuggestionService.swift
@@ -47,7 +47,7 @@ public extension Workspace {
filespace.codeMetadata.guessLineEnding(from: editor.lines.first)
let snapshot = FilespaceSuggestionSnapshot(
- linesHash: editor.lines.hashValue,
+ lines: editor.lines,
cursorPosition: editor.cursorPosition
)
diff --git a/Tool/Sources/XPCShared/Models.swift b/Tool/Sources/XPCShared/Models.swift
index f83317cf..82118502 100644
--- a/Tool/Sources/XPCShared/Models.swift
+++ b/Tool/Sources/XPCShared/Models.swift
@@ -53,12 +53,18 @@ public struct EditorContent: Codable {
public struct UpdatedContent: Codable {
public init(content: String, newSelection: CursorRange? = nil, modifications: [Modification]) {
self.content = content
- self.newSelection = newSelection
+ self.newSelections = if let newSelection { [newSelection] } else { [] }
+ self.modifications = modifications
+ }
+
+ public init(content: String, newSelections: [CursorRange], modifications: [Modification]) {
+ self.content = content
+ self.newSelections = newSelections
self.modifications = modifications
}
public var content: String
- public var newSelection: CursorRange?
+ public var newSelections: [CursorRange]
public var modifications: [Modification]
}
diff --git a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift
index a9a21c9f..76ee96a6 100644
--- a/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift
+++ b/Tool/Sources/XcodeInspector/Apps/XcodeAppInstanceInspector.swift
@@ -353,16 +353,21 @@ extension XcodeAppInstanceInspector {
for window in windows {
let workspaceIdentifier = workspaceIdentifier(window)
+ var traverseCount = 0
let tabs = {
guard let editArea = window.firstChild(where: { $0.description == "editor area" })
else { return Set() }
var allTabs = Set()
- let tabBars = editArea.children { $0.description == "tab bar" }
+ let tabBars = editArea.tabBars
for tabBar in tabBars {
- let tabs = tabBar.children { $0.roleDescription == "tab" }
- for tab in tabs {
- allTabs.insert(tab.title)
+ tabBar.traverse { element, _ in
+ traverseCount += 1
+ if element.roleDescription == "tab" {
+ allTabs.insert(element.title)
+ return .skipDescendants
+ }
+ return .continueSearching
}
}
return allTabs
@@ -416,3 +421,54 @@ private func isCompletionPanel(_ element: AXUIElement) -> Bool {
return matchXcode16CompletionPanel
}
+public extension AXUIElement {
+ var tabBars: [AXUIElement] {
+ // Searching by traversing with AXUIElement is (Xcode) resource consuming, we should skip
+ // as much as possible!
+
+ guard let editArea: AXUIElement = {
+ if description == "editor area" { return self }
+ return firstChild(where: { $0.description == "editor area" })
+ }() else { return [] }
+
+ var tabBars = [AXUIElement]()
+ editArea.traverse { element, _ in
+ let description = element.description
+ if description == "Tab Bar" {
+ element.traverse { element, _ in
+ if element.description == "tab bar" {
+ tabBars.append(element)
+ return .stopSearching
+ }
+ return .continueSearching
+ }
+
+ return .skipDescendantsAndSiblings
+ }
+
+ if element.identifier == "editor context" {
+ return .skipDescendantsAndSiblings
+ }
+
+ if element.isSourceEditor {
+ return .skipDescendantsAndSiblings
+ }
+
+ if description == "Code Coverage Ribbon" {
+ return .skipDescendants
+ }
+
+ if description == "Debug Area" {
+ return .skipDescendants
+ }
+
+ if description == "debug bar" {
+ return .skipDescendants
+ }
+
+ return .continueSearching
+ }
+
+ return tabBars
+ }
+}
diff --git a/Tool/Tests/CodeDiffTests/CodeDiffTests.swift b/Tool/Tests/CodeDiffTests/CodeDiffTests.swift
new file mode 100644
index 00000000..ce431133
--- /dev/null
+++ b/Tool/Tests/CodeDiffTests/CodeDiffTests.swift
@@ -0,0 +1,498 @@
+import Foundation
+import XCTest
+
+@testable import CodeDiff
+
+class CodeDiffTests: XCTestCase {
+ func test_diff_snippets_empty_snippets() {
+ XCTAssertEqual(
+ CodeDiff().diff(snippet: "", from: ""),
+ .init(sections: [
+ .init(oldSnippet: [.init(text: "")], newSnippet: [.init(text: "")]),
+ ])
+ )
+ }
+
+ func test_diff_snippets_from_empty_to_content() {
+ XCTAssertEqual(
+ CodeDiff().diff(
+ snippet: """
+ let foo = Foo()
+ foo.bar()
+ """,
+ from: ""
+ ),
+ .init(sections: [
+ .init(
+ oldSnippet: [.init(text: "", diff: .mutated(changes: []))],
+ newSnippet: [
+ .init(
+ text: "let foo = Foo()",
+ diff: .mutated(changes: [.init(
+ offset: 0,
+ element: "let foo = Foo()"
+ )])
+ ),
+ .init(
+ text: "foo.bar()",
+ diff: .mutated(changes: [.init(offset: 0, element: "foo.bar()")])
+ ),
+ ]
+ ),
+ ])
+ )
+ }
+
+ func test_diff_snippets_from_content_to_empty() {
+ XCTAssertEqual(
+ CodeDiff().diff(
+ snippet: "",
+ from: """
+ let foo = Foo()
+ foo.bar()
+ """
+ ),
+ .init(sections: [
+ .init(
+ oldSnippet: [
+ .init(
+ text: "let foo = Foo()",
+ diff: .mutated(changes: [.init(
+ offset: 0,
+ element: "let foo = Foo()"
+ )])
+ ),
+ .init(
+ text: "foo.bar()",
+ diff: .mutated(changes: [.init(offset: 0, element: "foo.bar()")])
+ ),
+ ],
+ newSnippet: [.init(text: "", diff: .mutated(changes: []))]
+ ),
+ ])
+ )
+ }
+
+ func test_diff_snippets_mutation() {
+ XCTAssertEqual(
+ CodeDiff().diff(
+ snippet: """
+ var foo = Bar()
+ foo.baz()
+ print(foo)
+ """,
+ from: """
+ let foo = Foo()
+ foo.bar()
+ """
+ ),
+ .init(sections: [
+ .init(
+ oldSnippet: [
+ .init(
+ text: "let foo = Foo()",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "let"),
+ .init(offset: 10, element: "Foo"),
+ ])
+ ),
+ .init(
+ text: "foo.bar()",
+ diff: .mutated(changes: [
+ .init(offset: 6, element: "r"),
+ ])
+ ),
+ ],
+ newSnippet: [
+ .init(
+ text: "var foo = Bar()",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "var"),
+ .init(offset: 10, element: "Bar"),
+ ])
+ ),
+ .init(
+ text: "foo.baz()",
+ diff: .mutated(changes: [
+ .init(offset: 6, element: "z"),
+ ])
+ ),
+ .init(
+ text: "print(foo)",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "print(foo)"),
+ ])
+ ),
+ ]
+ ),
+ ])
+ )
+ }
+
+ func test_diff_snippets_multiple_sections() {
+ XCTAssertEqual(
+ CodeDiff().diff(
+ snippet: """
+ var foo = Bar()
+ foo.baz()
+ // divider a
+ print(foo)
+ // divider b
+ // divider c
+ func bar() {
+ print(foo)
+ }
+ """,
+ from: """
+ let foo = Foo()
+ foo.bar()
+ // divider a
+ // divider b
+ // divider c
+ func bar() {}
+ """
+ ),
+ .init(sections: [
+ .init(
+ oldSnippet: [
+ .init(
+ text: "let foo = Foo()",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "let"),
+ .init(offset: 10, element: "Foo"),
+ ])
+ ),
+ .init(
+ text: "foo.bar()",
+ diff: .mutated(changes: [
+ .init(offset: 6, element: "r"),
+ ])
+ ),
+ ],
+ newSnippet: [
+ .init(
+ text: "var foo = Bar()",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "var"),
+ .init(offset: 10, element: "Bar"),
+ ])
+ ),
+ .init(
+ text: "foo.baz()",
+ diff: .mutated(changes: [
+ .init(offset: 6, element: "z"),
+ ])
+ ),
+ ]
+ ),
+ .init(
+ oldSnippet: [.init(text: "// divider a")],
+ newSnippet: [.init(text: "// divider a")]
+ ),
+ .init(
+ oldSnippet: [],
+ newSnippet: [
+ .init(
+ text: "print(foo)",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "print(foo)"),
+ ])
+ ),
+ ]
+ ),
+ .init(
+ oldSnippet: [.init(text: "// divider b"), .init(text: "// divider c")],
+ newSnippet: [.init(text: "// divider b"), .init(text: "// divider c")]
+ ),
+ .init(
+ oldSnippet: [
+ .init(
+ text: "func bar() {}",
+ diff: .mutated(changes: [
+ .init(offset: 12, element: "}"),
+ ])
+ ),
+ ],
+ newSnippet: [
+ .init(
+ text: "func bar() {",
+ diff: .mutated(changes: [])
+ ),
+ .init(
+ text: " print(foo)",
+ diff: .mutated(changes: [.init(offset: 0, element: " print(foo)")])
+ ),
+ .init(
+ text: "}",
+ diff: .mutated(changes: [.init(offset: 0, element: "}")])
+ ),
+ ]
+ ),
+ ])
+ )
+ }
+
+ func test_diff_snippets_multiple_sections_beginning_unchanged() {
+ XCTAssertEqual(
+ CodeDiff().diff(
+ snippet: """
+ // unchanged
+ // unchanged
+ var foo = Bar()
+ foo.baz()
+ // divider a
+ print(foo)
+ """,
+ from: """
+ // unchanged
+ // unchanged
+ let foo = Foo()
+ foo.bar()
+ // divider a
+ """
+ ),
+ .init(sections: [
+ .init(
+ oldSnippet: [.init(text: "// unchanged"), .init(text: "// unchanged")],
+ newSnippet: [.init(text: "// unchanged"), .init(text: "// unchanged")]
+ ),
+ .init(
+ oldSnippet: [
+ .init(
+ text: "let foo = Foo()",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "let"),
+ .init(offset: 10, element: "Foo"),
+ ])
+ ),
+ .init(
+ text: "foo.bar()",
+ diff: .mutated(changes: [
+ .init(offset: 6, element: "r"),
+ ])
+ ),
+ ],
+ newSnippet: [
+ .init(
+ text: "var foo = Bar()",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "var"),
+ .init(offset: 10, element: "Bar"),
+ ])
+ ),
+ .init(
+ text: "foo.baz()",
+ diff: .mutated(changes: [
+ .init(offset: 6, element: "z"),
+ ])
+ ),
+ ]
+ ),
+ .init(
+ oldSnippet: [.init(text: "// divider a")],
+ newSnippet: [.init(text: "// divider a")]
+ ),
+ .init(
+ oldSnippet: [],
+ newSnippet: [
+ .init(
+ text: "print(foo)",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "print(foo)"),
+ ])
+ ),
+ ]
+ ),
+ ])
+ )
+ }
+
+ func test_diff_snippets_multiple_sections_beginning_unchanged_reversed() {
+ XCTAssertEqual(
+ CodeDiff().diff(
+ snippet: """
+ // unchanged
+ // unchanged
+ let foo = Foo()
+ foo.bar()
+ // divider a
+ """,
+ from: """
+ // unchanged
+ // unchanged
+ var foo = Bar()
+ foo.baz()
+ // divider a
+ print(foo)
+ """
+ ),
+ .init(sections: [
+ .init(
+ oldSnippet: [.init(text: "// unchanged"), .init(text: "// unchanged")],
+ newSnippet: [.init(text: "// unchanged"), .init(text: "// unchanged")]
+ ),
+ .init(
+ oldSnippet: [
+ .init(
+ text: "var foo = Bar()",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "var"),
+ .init(offset: 10, element: "Bar"),
+ ])
+ ),
+ .init(
+ text: "foo.baz()",
+ diff: .mutated(changes: [
+ .init(offset: 6, element: "z"),
+ ])
+ ),
+ ],
+ newSnippet: [
+ .init(
+ text: "let foo = Foo()",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "let"),
+ .init(offset: 10, element: "Foo"),
+ ])
+ ),
+ .init(
+ text: "foo.bar()",
+ diff: .mutated(changes: [
+ .init(offset: 6, element: "r"),
+ ])
+ ),
+ ]
+ ),
+ .init(
+ oldSnippet: [.init(text: "// divider a")],
+ newSnippet: [.init(text: "// divider a")]
+ ),
+ .init(
+ oldSnippet: [.init(
+ text: "print(foo)",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "print(foo)"),
+ ])
+ )],
+ newSnippet: []
+ ),
+ ])
+ )
+ }
+
+ func test_diff_snippets_multiple_sections_more_unbalanced_sections_reversed() {
+ XCTAssertEqual(
+ CodeDiff().diff(
+ snippet: """
+ let foo = Foo()
+ foo.bar()
+ // divider a
+ // divider b
+ // divider c
+ func bar() {}
+ """,
+ from: """
+ var foo = Bar()
+ foo.baz()
+ // divider a
+ print(foo)
+ // divider b
+ print(foo)
+ // divider c
+ func bar() {
+ print(foo)
+ }
+ """
+ ),
+ .init(sections: [
+ .init(
+ oldSnippet: [
+ .init(
+ text: "var foo = Bar()",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "var"),
+ .init(offset: 10, element: "Bar"),
+ ])
+ ),
+ .init(
+ text: "foo.baz()",
+ diff: .mutated(changes: [
+ .init(offset: 6, element: "z"),
+ ])
+ ),
+ ],
+ newSnippet: [
+ .init(
+ text: "let foo = Foo()",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "let"),
+ .init(offset: 10, element: "Foo"),
+ ])
+ ),
+ .init(
+ text: "foo.bar()",
+ diff: .mutated(changes: [
+ .init(offset: 6, element: "r"),
+ ])
+ ),
+ ]
+ ),
+ .init(
+ oldSnippet: [.init(text: "// divider a")],
+ newSnippet: [.init(text: "// divider a")]
+ ),
+ .init(
+ oldSnippet: [.init(
+ text: "print(foo)",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "print(foo)"),
+ ])
+ ),],
+ newSnippet: []
+ ),
+ .init(
+ oldSnippet: [.init(text: "// divider b")],
+ newSnippet: [.init(text: "// divider b")]
+ ),
+ .init(
+ oldSnippet: [.init(
+ text: "print(foo)",
+ diff: .mutated(changes: [
+ .init(offset: 0, element: "print(foo)"),
+ ])
+ )],
+ newSnippet: []
+ ),
+ .init(
+ oldSnippet: [.init(text: "// divider c")],
+ newSnippet: [.init(text: "// divider c")]
+ ),
+ .init(
+ oldSnippet: [
+ .init(
+ text: "func bar() {",
+ diff: .mutated(changes: [])
+ ),
+ .init(
+ text: " print(foo)",
+ diff: .mutated(changes: [.init(offset: 0, element: " print(foo)")])
+ ),
+ .init(
+ text: "}",
+ diff: .mutated(changes: [.init(offset: 0, element: "}")])
+ ),
+ ],
+ newSnippet: [
+ .init(
+ text: "func bar() {}",
+ diff: .mutated(changes: [
+ .init(offset: 12, element: "}"),
+ ])
+ ),
+ ]
+ ),
+ ])
+ )
+ }
+}
+
diff --git a/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift b/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift
index afa11eda..1cf793b5 100644
--- a/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift
+++ b/Tool/Tests/OpenAIServiceTests/AutoManagedChatGPTMemoryRetrievedContentTests.swift
@@ -10,11 +10,7 @@ class AutoManagedChatGPTMemoryRetrievedContentTests: XCTestCase {
func ref(_ text: String) -> ChatMessage.Reference {
.init(
title: "",
- subTitle: "",
content: text,
- uri: "",
- startLine: nil,
- endLine: nil,
kind: .text
)
}
diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift
deleted file mode 100644
index 99646988..00000000
--- a/Tool/Tests/OpenAIServiceTests/ChatGPTServiceFieldTests.swift
+++ /dev/null
@@ -1,40 +0,0 @@
-import XCTest
-@testable import OpenAIService
-
-final class ChatGPTServiceFieldTests: XCTestCase {
- let skip = true
-
- func test_calling_the_api() async throws {
- let service = ChatGPTService()
-
- if skip { return }
-
- do {
- let stream = try await service.send(content: "Hello")
- for try await text in stream {
- print(text)
- }
- } catch {
- print("🔴", error.localizedDescription)
- }
-
- XCTFail("🔴 Please reset skip to true.")
- }
-
- func test_calling_the_api_with_function_calling() async throws {
- let service = ChatGPTService()
-
- if skip { return }
-
- do {
- let stream = try await service.send(content: "Hello")
- for try await text in stream {
- print(text)
- }
- } catch {
- print("🔴", error.localizedDescription)
- }
-
- XCTFail("🔴 Please reset skip to true.")
- }
-}
diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift
new file mode 100644
index 00000000..12cd811e
--- /dev/null
+++ b/Tool/Tests/OpenAIServiceTests/ChatGPTServiceTests.swift
@@ -0,0 +1,580 @@
+import AIModel
+import ChatBasic
+import Dependencies
+import Foundation
+import XCTest
+
+@testable import OpenAIService
+
+class ChatGPTServiceTests: XCTestCase {
+ func test_send_memory_and_handles_responses_with_chunks() async throws {
+ let api = ChunksChatCompletionsStreamAPI(chunks: [
+ .token("hello"),
+ .token(" "),
+ .token("world"),
+ .token("!"),
+ .finish(reason: "finished"),
+ ])
+ let builder = APIBuilder(api: api)
+ let memory = EmptyChatGPTMemory()
+ let stream = withDependencies { values in
+ values.uuid = .incrementing
+ values.date = .constant(Date())
+ values.chatCompletionsAPIBuilder = builder
+ } operation: {
+ let service = ChatGPTService(
+ configuration: EmptyConfiguration(),
+ functionProvider: NoChatGPTFunctionProvider()
+ )
+ return service.send(memory)
+ }
+
+ let response = try await stream.asArray()
+ XCTAssertEqual(response, [
+ .partialText("hello"),
+ .partialText(" "),
+ .partialText("world"),
+ .partialText("!"),
+ ])
+
+ let history = await memory.history
+ XCTAssertEqual(history, [
+ .init(
+ id: "00000000-0000-0000-0000-000000000000",
+ role: .assistant,
+ content: "hello world!"
+ ),
+ ])
+ }
+
+ func test_send_memory_returns_tool_calls() async throws {
+ let api = ChunksChatCompletionsStreamAPI(
+ chunks: [
+ .partialToolCalls([
+ .init(index: 0, id: "1", type: "function", function: .init(name: "foo")),
+ .init(index: 1, id: "2", type: "function", function: .init(name: "bar")),
+ ]),
+ .partialToolCalls([
+ .init(
+ index: 0,
+ id: "1",
+ type: "function",
+ function: .init(arguments: "{\"foo\": \"hi\"}")
+ ),
+ .init(
+ index: 1,
+ id: "2",
+ type: "function",
+ function: .init(arguments: "{\"bar\": \"bye\"}")
+ ),
+ ]),
+ ]
+ )
+ let builder = APIBuilder(api: api)
+ let memory = EmptyChatGPTMemory()
+ let stream = withDependencies { values in
+ values.uuid = .incrementing
+ values.date = .constant(Date())
+ values.chatCompletionsAPIBuilder = builder
+ } operation: {
+ let service = ChatGPTService(
+ configuration: EmptyConfiguration(),
+ functionProvider: FunctionProvider()
+ )
+ return service.send(memory)
+ }
+
+ let response = try await stream.asArray()
+ XCTAssertEqual(response, [
+ .toolCalls([
+ .init(
+ id: "1",
+ type: "function",
+ function: .init(name: "foo", arguments: "{\"foo\": \"hi\"}"),
+ response: nil
+ ),
+ .init(
+ id: "2",
+ type: "function",
+ function: .init(name: "bar", arguments: "{\"bar\": \"bye\"}"),
+ response: nil
+ ),
+ ]),
+ ])
+
+ let history = await memory.history
+ XCTAssertEqual(history, [
+ .init(
+ id: "00000000-0000-0000-0000-000000000000",
+ role: .assistant,
+ content: nil,
+ toolCalls: [
+ .init(
+ id: "1",
+ type: "function",
+ function: .init(name: "foo", arguments: "{\"foo\": \"hi\"}"),
+ response: nil
+ ),
+ .init(
+ id: "2",
+ type: "function",
+ function: .init(name: "bar", arguments: "{\"bar\": \"bye\"}"),
+ response: nil
+ ),
+ ]
+ ),
+ ])
+ }
+
+ func test_send_memory_and_automatically_handles_multiple_tool_calls() async throws {
+ let api = ChunksChatCompletionsStreamAPI(chunks: [[
+ .partialToolCalls([
+ .init(index: 0, id: "1", type: "function", function: .init(name: "foo")),
+ .init(index: 1, id: "2", type: "function", function: .init(name: "bar")),
+ ]),
+ .partialToolCalls([
+ .init(
+ index: 0,
+ id: "1",
+ type: "function",
+ function: .init(arguments: "{\"foo\": \"hi\"}")
+ ),
+ .init(
+ index: 1,
+ id: "2",
+ type: "function",
+ function: .init(arguments: "{\"bar\": \"bye\"}")
+ ),
+ ]),
+ ],
+ [
+ .token("hello"),
+ .token(" "),
+ .token("world"),
+ .token("!"),
+ .finish(reason: "finished"),
+ ],
+ ])
+ let builder = APIBuilder(api: api)
+ let memory = EmptyChatGPTMemory()
+ let stream = withDependencies { values in
+ values.uuid = .incrementing
+ values.date = .constant(Date())
+ values.chatCompletionsAPIBuilder = builder
+ } operation: {
+ let service = ChatGPTService(
+ configuration: EmptyConfiguration().overriding {
+ $0.runFunctionsAutomatically = true
+ },
+ functionProvider: FunctionProvider()
+ )
+ return service.send(memory)
+ }
+
+ let response = try await stream.asArray()
+ XCTAssertEqual(response, [
+ .status("start foo 1"),
+ .status("start foo 2"),
+ .status("start foo 3"),
+ .status("start bar 1"),
+ .status("start bar 2"),
+ .status("start bar 3"),
+ .status("foo hi"),
+ .status("bar bye"),
+ .partialText("hello"),
+ .partialText(" "),
+ .partialText("world"),
+ .partialText("!"),
+ ])
+
+ let history = await memory.history
+ XCTAssertEqual(history, [
+ .init(
+ id: "00000000-0000-0000-0000-000000000000",
+ role: .assistant,
+ content: nil,
+ toolCalls: [
+ .init(
+ id: "1",
+ type: "function",
+ function: .init(name: "foo", arguments: "{\"foo\": \"hi\"}"),
+ response: .init(content: "foo hi", summary: "foo hi")
+ ),
+ .init(
+ id: "2",
+ type: "function",
+ function: .init(name: "bar", arguments: "{\"bar\": \"bye\"}"),
+ response: .init(content: "Error: bar error", summary: "Error: bar error")
+ ),
+ ]
+ ),
+ .init(
+ id: "00000000-0000-0000-0000-000000000001",
+ role: .assistant,
+ content: "hello world!"
+ ),
+ ])
+ }
+
+ func test_send_memory_and_automatically_handles_unknown_tool_call() async throws {
+ let api = ChunksChatCompletionsStreamAPI(chunks: [[
+ .partialToolCalls([
+ .init(index: 0, id: "1", type: "function", function: .init(name: "python")),
+ .init(index: 1, id: "2", type: "function", function: .init(name: "unknown")),
+ ]),
+ .partialToolCalls([
+ .init(
+ index: 0,
+ id: "1",
+ type: "function",
+ function: .init(arguments: "{\"foo\": \"hi\"}")
+ ),
+ .init(
+ index: 1,
+ id: "2",
+ type: "function",
+ function: .init(arguments: "{\"foo\": \"hi\"}")
+ ),
+ ]),
+ ],
+ [
+ .token("result a"),
+ ],
+ [
+ .token("result b"),
+ ],
+ [
+ .token("hello"),
+ .token(" "),
+ .token("world"),
+ .token("!"),
+ .finish(reason: "finished"),
+ ],
+ ])
+ let builder = APIBuilder(api: api)
+ let memory = EmptyChatGPTMemory()
+ let stream = withDependencies { values in
+ values.uuid = .incrementing
+ values.date = .constant(Date())
+ values.chatCompletionsAPIBuilder = builder
+ } operation: {
+ let service = ChatGPTService(
+ configuration: EmptyConfiguration().overriding {
+ $0.runFunctionsAutomatically = true
+ },
+ functionProvider: FunctionProvider()
+ )
+ return service.send(memory)
+ }
+
+ let response = try await stream.asArray()
+ XCTAssertEqual(response, [
+ .partialText("hello"),
+ .partialText(" "),
+ .partialText("world"),
+ .partialText("!"),
+ ])
+
+ let history = await memory.history
+ XCTAssertEqual(history, [
+ .init(
+ id: "00000000-0000-0000-0000-000000000000",
+ role: .assistant,
+ content: nil,
+ toolCalls: [
+ .init(
+ id: "1",
+ type: "function",
+ function: .init(name: "python", arguments: "{\"foo\": \"hi\"}"),
+ response: .init(content: "result a", summary: "Finished running function.")
+ ),
+ .init(
+ id: "2",
+ type: "function",
+ function: .init(name: "unknown", arguments: "{\"foo\": \"hi\"}"),
+ response: .init(content: "result b", summary: "Finished running function.")
+ ),
+ ]
+ ),
+ .init(
+ id: "00000000-0000-0000-0000-000000000003",
+ role: .assistant,
+ content: "hello world!"
+ ),
+ ])
+ }
+
+ func test_send_memory_and_handles_error() async throws {
+ struct E: Error, LocalizedError {
+ var errorDescription: String? { "error happens" }
+ }
+ let api = ChunksChatCompletionsStreamAPI(chunks: [
+ .token("hello"),
+ .token(" "),
+ .failure(E())
+ ])
+ let builder = APIBuilder(api: api)
+ let memory = EmptyChatGPTMemory()
+ let stream = withDependencies { values in
+ values.uuid = .incrementing
+ values.date = .constant(Date())
+ values.chatCompletionsAPIBuilder = builder
+ } operation: {
+ let service = ChatGPTService(
+ configuration: EmptyConfiguration(),
+ functionProvider: NoChatGPTFunctionProvider()
+ )
+ return service.send(memory)
+ }
+
+ var results = [ChatGPTResponse]()
+ let expectError = expectation(description: "error")
+ do {
+ for try await item in stream {
+ results.append(item)
+ }
+ } catch is E {
+ expectError.fulfill()
+ } catch {
+ XCTFail("Incorrect Error")
+ }
+
+ await fulfillment(of: [expectError], timeout: 1)
+ let history = await memory.history
+ XCTAssertEqual(history, [
+ .init(
+ id: "00000000-0000-0000-0000-000000000000",
+ role: .assistant,
+ content: "hello "
+ ),
+ .init(
+ id: "00000000-0000-0000-0000-000000000001",
+ role: .assistant,
+ content: "error happens"
+ ),
+ ])
+ }
+
+ func test_send_memory_and_handles_cancellation() async throws {
+ let api = ChunksChatCompletionsStreamAPI(chunks: [
+ .token("hello"),
+ .token(" "),
+ .failure(CancellationError())
+ ])
+ let builder = APIBuilder(api: api)
+ let memory = EmptyChatGPTMemory()
+ let stream = withDependencies { values in
+ values.uuid = .incrementing
+ values.date = .constant(Date())
+ values.chatCompletionsAPIBuilder = builder
+ } operation: {
+ let service = ChatGPTService(
+ configuration: EmptyConfiguration(),
+ functionProvider: NoChatGPTFunctionProvider()
+ )
+ return service.send(memory)
+ }
+
+ var results = [ChatGPTResponse]()
+ let expectError = expectation(description: "error")
+ do {
+ for try await item in stream {
+ results.append(item)
+ }
+ } catch is CancellationError {
+ expectError.fulfill()
+ } catch {
+ XCTFail("Incorrect Error")
+ }
+
+ await fulfillment(of: [expectError], timeout: 1)
+ let history = await memory.history
+ XCTAssertEqual(history, [
+ .init(
+ id: "00000000-0000-0000-0000-000000000000",
+ role: .assistant,
+ content: "hello "
+ ),
+ ])
+ }
+}
+
+private struct APIBuilder: ChatCompletionsAPIBuilder {
+ let api: ChatCompletionsStreamAPI
+
+ func buildStreamAPI(
+ model: ChatModel,
+ endpoint: URL,
+ apiKey: String,
+ requestBody: ChatCompletionsRequestBody
+ ) -> any ChatCompletionsStreamAPI {
+ api
+ }
+
+ func buildNonStreamAPI(
+ model: ChatModel,
+ endpoint: URL,
+ apiKey: String,
+ requestBody: ChatCompletionsRequestBody
+ ) -> any ChatCompletionsAPI {
+ fatalError()
+ }
+}
+
+private struct EmptyConfiguration: ChatGPTConfiguration {
+ var model: AIModel.ChatModel? { .init(id: "", name: "", format: .openAI, info: .init()) }
+ var temperature: Double { 0 }
+ var stop: [String] { [] }
+ var maxTokens: Int { 99999 }
+ var minimumReplyTokens: Int { 99999 }
+ var runFunctionsAutomatically: Bool { false }
+ var shouldEndTextWindow: (String) -> Bool = { _ in true }
+}
+
+private class ChunksChatCompletionsStreamAPI: ChatCompletionsStreamAPI {
+ private(set) var chunks: [[Result]]
+ init(chunks: [Result]) {
+ self.chunks = [chunks]
+ }
+
+ init(chunks: [[Result]]) {
+ self.chunks = chunks
+ }
+
+ func callAsFunction() async throws
+ -> AsyncThrowingStream
+ {
+ let chunks = self.chunks.removeFirst()
+ return .init {
+ for chunk in chunks {
+ switch chunk {
+ case let .success(chunk):
+ $0.yield(chunk)
+ case let .failure(error):
+ $0.finish(throwing: error)
+ return
+ }
+ }
+ $0.finish()
+ }
+ }
+}
+
+private struct ThrowingChatCompletionsStreamAPI: ChatCompletionsStreamAPI {
+ let error: any Error
+ func callAsFunction() async throws
+ -> AsyncThrowingStream
+ {
+ throw error
+ }
+}
+
+private extension Result {
+ static func token(_ string: String) -> Result {
+ .success(.init(
+ id: "1",
+ object: "object",
+ model: "model",
+ message: .some(.init(role: .assistant, content: string)),
+ finishReason: nil
+ ))
+ }
+
+ static func partialToolCalls(_ toolCalls: [ChatCompletionsStreamDataChunk.Delta.ToolCall])
+ -> Result
+ {
+ .success(.init(
+ id: "1",
+ object: "object",
+ model: "model",
+ message: .some(.init(
+ role: .assistant,
+ content: nil,
+ toolCalls: toolCalls
+ )),
+ finishReason: nil
+ ))
+ }
+
+ static func finish(reason: String) -> Result {
+ .success(.init(
+ id: "1",
+ object: "object",
+ model: "model",
+ message: .some(.init(role: .assistant, content: nil)),
+ finishReason: reason
+ ))
+ }
+}
+
+private struct FunctionProvider: ChatGPTFunctionProvider {
+ struct Foo: ChatGPTFunction {
+ struct Arguments: Codable {
+ var foo: String
+ }
+
+ struct Result: ChatGPTFunctionResult {
+ var result: String
+ var botReadableContent: String { result }
+ }
+
+ var name: String { "foo" }
+
+ var description: String { "foo" }
+
+ var argumentSchema: ChatBasic.JSONSchemaValue = .string("")
+
+ func prepare(reportProgress: @escaping ReportProgress) async {
+ await reportProgress("start foo 1")
+ await reportProgress("start foo 2")
+ await reportProgress("start foo 3")
+ }
+
+ func call(
+ arguments: Arguments,
+ reportProgress: @escaping ReportProgress
+ ) async throws -> Result {
+ await reportProgress("foo \(arguments.foo)")
+ return .init(result: "foo \(arguments.foo)")
+ }
+ }
+
+ struct Bar: ChatGPTFunction {
+ struct Arguments: Codable {
+ var bar: String
+ }
+
+ struct Result: ChatGPTFunctionResult {
+ var result: String
+ var botReadableContent: String { result }
+ }
+
+ var name: String { "bar" }
+
+ var description: String { "bar" }
+
+ var argumentSchema: ChatBasic.JSONSchemaValue = .string("")
+
+ func prepare(reportProgress: @escaping ReportProgress) async {
+ await reportProgress("start bar 1")
+ await reportProgress("start bar 2")
+ await reportProgress("start bar 3")
+ }
+
+ func call(
+ arguments: Arguments,
+ reportProgress: @escaping ReportProgress
+ ) async throws -> Result {
+ await reportProgress("bar \(arguments.bar)")
+ struct E: Error, LocalizedError {
+ var errorDescription: String? { "bar error" }
+ }
+ throw E()
+ }
+ }
+
+ var functions: [any ChatGPTFunction] = [Foo(), Bar()]
+
+ var functionCallStrategy: OpenAIService.FunctionCallStrategy? { nil }
+}
+
diff --git a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift b/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift
deleted file mode 100644
index 5f7c50db..00000000
--- a/Tool/Tests/OpenAIServiceTests/ChatGPTStreamTests.swift
+++ /dev/null
@@ -1,548 +0,0 @@
-import ChatBasic
-import Dependencies
-import XCTest
-@testable import OpenAIService
-
-final class ChatGPTStreamTests: XCTestCase {
- func test_sending_message() async throws {
- let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s")
- let configuration = UserPreferenceChatGPTConfiguration().overriding {
- $0.model = .init(id: "id", name: "name", format: .openAI, info: .init())
- }
- let functionProvider = NoChatGPTFunctionProvider()
- let service = ChatGPTService(
- memory: memory,
- configuration: configuration,
- functionProvider: functionProvider
- )
- var requestBody: ChatCompletionsRequestBody?
- service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in
- requestBody = _requestBody
- return MockCompletionStreamAPI_Message()
- }
-
- try await withDependencies { values in
- values.uuid = .incrementing
- values.date = .constant(.init(timeIntervalSince1970: 0))
- } operation: {
- let stream = try await service.send(content: "Hello")
- var all = [String]()
- for try await text in stream {
- all.append(text)
- let history = await memory.history
- XCTAssertTrue(
- history.last?.content?.hasPrefix(all.joined()) ?? false,
- "History is not updated"
- )
- }
-
- XCTAssertEqual(requestBody?.messages, [
- .init(role: .system, content: "system"),
- .init(role: .user, content: "Hello"),
- ], "System prompt is not included")
-
- XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is not correct")
-
- var history = await memory.history
- for (i, _) in history.enumerated() {
- history[i].tokensCount = nil
- }
- XCTAssertEqual(history, [
- .init(
- id: "s",
- role: .system,
- content: "system"
- ),
- .init(id: "00000000-0000-0000-0000-000000000000", role: .user, content: "Hello"),
- .init(
- id: "00000000-0000-0000-0000-0000000000010.0",
- role: .assistant,
- content: "hellomyfriends"
- ),
- ], "History is not updated")
-
- XCTAssertEqual(requestBody?.tools, nil, "Function schema is not submitted")
- }
- }
-
- func test_handling_function_call() async throws {
- let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s")
- let configuration = UserPreferenceChatGPTConfiguration().overriding {
- $0.model = .init(id: "id", name: "name", format: .openAI, info: .init())
- }
- let functionProvider = FunctionProvider()
- let service = ChatGPTService(
- memory: memory,
- configuration: configuration,
- functionProvider: functionProvider
- )
- var requestBody: ChatCompletionsRequestBody?
- service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in
- requestBody = _requestBody
- if _requestBody.messages.count <= 2 {
- return MockCompletionStreamAPI_Function()
- }
- return MockCompletionStreamAPI_Message()
- }
-
- try await withDependencies { values in
- values.uuid = .incrementing
- values.date = .constant(.init(timeIntervalSince1970: 0))
- } operation: {
- let stream = try await service.send(content: "Hello")
- var all = [String]()
- for try await text in stream {
- all.append(text)
- let history = await memory.history
- XCTAssertEqual(history.last?.id, "00000000-0000-0000-0000-0000000000030.0")
- XCTAssertTrue(
- history.last?.content?.hasPrefix(all.joined()) ?? false,
- "History is not updated"
- )
- }
-
- XCTAssertEqual(requestBody?.messages, [
- .init(role: .system, content: "system"),
- .init(role: .user, content: "Hello"),
- .init(
- role: .assistant, content: "",
- toolCalls: [
- .init(
- id: "id",
- type: "function",
- function: .init(name: "function", arguments: "{\n\"foo\": 1\n}")
- )]
- ),
- .init(role: .tool, content: "Function is called.", toolCallId: "id"),
- ], "System prompt is not included")
-
- XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is not correct")
-
- var history = await memory.history
- for (i, _) in history.enumerated() {
- history[i].tokensCount = nil
- }
- XCTAssertEqual(history, [
- .init(id: "s", role: .system, content: "system"),
- .init(id: "00000000-0000-0000-0000-000000000000", role: .user, content: "Hello"),
- .init(
- id: "00000000-0000-0000-0000-0000000000010.0",
- role: .assistant,
- content: nil,
- toolCalls: [
- .init(
- id: "id",
- type: "function",
- function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"),
- response: .init(content: "Function is called.", summary: nil)
- ),
- ]
- ),
- .init(
- id: "00000000-0000-0000-0000-0000000000030.0",
- role: .assistant,
- content: "hellomyfriends"
- ),
- ], "History is not updated")
-
- XCTAssertEqual(requestBody?.tools, [
- EmptyFunction(),
- ].map {
- .init(
- type: "function",
- function: .init(
- name: $0.name,
- description: $0.description,
- parameters: $0.argumentSchema
- )
- )
- }, "Function schema is not submitted")
- }
- }
-
- func test_handling_multiple_function_call() async throws {
- let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s")
- let configuration = UserPreferenceChatGPTConfiguration().overriding {
- $0.model = .init(id: "id", name: "name", format: .openAI, info: .init())
- }
- let functionProvider = FunctionProvider()
- let service = ChatGPTService(
- memory: memory,
- configuration: configuration,
- functionProvider: functionProvider
- )
- var requestBody: ChatCompletionsRequestBody?
-
- service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in
- requestBody = _requestBody
- if _requestBody.messages.count <= 4 {
- return MockCompletionStreamAPI_Function(count: 3)
- }
- return MockCompletionStreamAPI_Message()
- }
-
- try await withDependencies { values in
- values.uuid = .incrementing
- values.date = .constant(.init(timeIntervalSince1970: 0))
- } operation: {
- let stream = try await service.send(content: "Hello")
- var all = [String]()
- for try await text in stream {
- all.append(text)
- let history = await memory.history
- XCTAssertEqual(history.last?.id, "00000000-0000-0000-0000-0000000000030.0")
- XCTAssertTrue(
- history.last?.content?.hasPrefix(all.joined()) ?? false,
- "History is not updated"
- )
- }
-
- XCTAssertEqual(requestBody?.messages, [
- .init(role: .system, content: "system"),
- .init(role: .user, content: "Hello"),
- .init(
- role: .assistant, content: "",
- toolCalls: [
- .init(
- id: "id",
- type: "function",
- function: .init(name: "function", arguments: "{\n\"foo\": 1\n}")
- ),
- .init(
- id: "id2",
- type: "function",
- function: .init(name: "function", arguments: "{\n\"foo\": 1\n}")
- ),
- .init(
- id: "id3",
- type: "function",
- function: .init(name: "function", arguments: "{\n\"foo\": 1\n}")
- ),
- ]
- ),
- .init(
- role: .tool,
- content: "Function is called.",
- toolCallId: "id"
- ),
- .init(
- role: .tool,
- content: "Function is called.",
- toolCallId: "id2"
- ),
- .init(
- role: .tool,
- content: "Function is called.",
- toolCallId: "id3"
- ),
- ], "System prompt is not included")
-
- XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is not correct")
-
- var history = await memory.history
- for (i, _) in history.enumerated() {
- history[i].tokensCount = nil
- }
- XCTAssertEqual(history, [
- .init(id: "s", role: .system, content: "system"),
- .init(id: "00000000-0000-0000-0000-000000000000", role: .user, content: "Hello"),
- .init(
- id: "00000000-0000-0000-0000-0000000000010.0",
- role: .assistant,
- content: nil,
- toolCalls: [
- .init(
- id: "id",
- type: "function",
- function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"),
- response: .init(content: "Function is called.", summary: nil)
- ),
- .init(
- id: "id2",
- type: "function",
- function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"),
- response: .init(content: "Function is called.", summary: nil)
- ),
- .init(
- id: "id3",
- type: "function",
- function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"),
- response: .init(content: "Function is called.", summary: nil)
- ),
- ]
- ),
- .init(
- id: "00000000-0000-0000-0000-0000000000030.0",
- role: .assistant,
- content: "hellomyfriends"
- ),
- ], "History is not updated")
-
- XCTAssertEqual(requestBody?.tools, [
- EmptyFunction(),
- ].map {
- .init(
- type: "function",
- function: .init(
- name: $0.name,
- description: $0.description,
- parameters: $0.argumentSchema
- )
- )
- }, "Function schema is not submitted")
- }
- }
-
- func test_function_calling_unsupported() async throws {
- let memory = ConversationChatGPTMemory(systemPrompt: "system", systemMessageId: "s")
- let configuration = UserPreferenceChatGPTConfiguration().overriding {
- $0.model = .init(
- id: "id",
- name: "name",
- format: .openAI,
- info: .init(supportsFunctionCalling: false)
- )
- }
- let functionProvider = FunctionProvider()
- let service = ChatGPTService(
- memory: memory,
- configuration: configuration,
- functionProvider: functionProvider
- )
- var requestBody: ChatCompletionsRequestBody?
- service.changeBuildCompletionStreamAPI { _, _, _, _requestBody, _ in
- requestBody = _requestBody
- if _requestBody.messages.count <= 2 {
- return MockCompletionStreamAPI_Function()
- }
- return MockCompletionStreamAPI_Message()
- }
-
- try await withDependencies { values in
- values.uuid = .incrementing
- values.date = .constant(.init(timeIntervalSince1970: 0))
- } operation: {
- let stream = try await service.send(content: "Hello")
- var all = [String]()
- for try await text in stream {
- all.append(text)
- let history = await memory.history
- XCTAssertEqual(history.last?.id, "00000000-0000-0000-0000-0000000000030.0")
- XCTAssertTrue(
- history.last?.content?.hasPrefix(all.joined()) ?? false,
- "History is not updated"
- )
- }
-
- XCTAssertEqual(requestBody?.messages, [
- .init(role: .system, content: "system"),
- .init(role: .user, content: "Hello"),
- .init(
- role: .assistant, content: ""
- ),
- .init(role: .user, content: "Function is called."),
- ], "System prompt is not included")
-
- XCTAssertEqual(all, ["hello", "my", "friends"], "Text stream is not correct")
-
- var history = await memory.history
- for (i, _) in history.enumerated() {
- history[i].tokensCount = nil
- }
- XCTAssertEqual(history, [
- .init(id: "s", role: .system, content: "system"),
- .init(id: "00000000-0000-0000-0000-000000000000", role: .user, content: "Hello"),
- .init(
- id: "00000000-0000-0000-0000-0000000000010.0",
- role: .assistant,
- content: nil,
- toolCalls: [
- .init(
- id: "id",
- type: "function",
- function: .init(name: "function", arguments: "{\n\"foo\": 1\n}"),
- response: .init(content: "Function is called.", summary: nil)
- ),
- ]
- ),
- .init(
- id: "00000000-0000-0000-0000-0000000000030.0",
- role: .assistant,
- content: "hellomyfriends"
- ),
- ], "History is not updated")
-
- XCTAssertEqual(requestBody?.tools, nil, "Functions should be nil")
- }
- }
-}
-
-extension ChatGPTStreamTests {
- struct MockCompletionStreamAPI_Message: ChatCompletionsStreamAPI {
- @Dependency(\.uuid) var uuid
- func callAsFunction() async throws
- -> AsyncThrowingStream
- {
- let id = uuid().uuidString
- return AsyncThrowingStream { continuation in
- let chunks: [ChatCompletionsStreamDataChunk] = [
- .init(
- id: id,
- object: "",
- model: "",
- message: .init(role: .assistant),
- finishReason: ""
- ),
- .init(
- id: id,
- object: "",
- model: "",
- message: .init(content: "hello"),
- finishReason: ""
- ),
- .init(
- id: id,
- object: "",
- model: "",
- message: .init(content: "my"),
- finishReason: ""
- ),
- .init(
- id: id,
- object: "",
- model: "",
- message: .init(content: "friends"),
- finishReason: ""
- ),
- ]
- for chunk in chunks {
- continuation.yield(chunk)
- }
- continuation.finish()
- }
- }
- }
-
- struct MockCompletionStreamAPI_Function: ChatCompletionsStreamAPI {
- @Dependency(\.uuid) var uuid
- var count: Int = 1
- func callAsFunction() async throws
- -> AsyncThrowingStream
- {
- let id = uuid().uuidString
- return AsyncThrowingStream { continuation in
- for i in 0.. String {
- "Function is called."
- }
- }
-
- struct FunctionProvider: ChatGPTFunctionProvider {
- var functionCallStrategy: OpenAIService.FunctionCallStrategy? { nil }
-
- var functions: [any ChatGPTFunction] { [EmptyFunction()] }
- }
-}
-
diff --git a/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift b/Tool/Tests/OpenAIServiceTests/GoogleAIChatCompletionsAPITests.swift
similarity index 57%
rename from Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift
rename to Tool/Tests/OpenAIServiceTests/GoogleAIChatCompletionsAPITests.swift
index e92d5079..7044fa74 100644
--- a/Tool/Tests/OpenAIServiceTests/ReformatPromptToBeGoogleAICompatibleTests.swift
+++ b/Tool/Tests/OpenAIServiceTests/GoogleAIChatCompletionsAPITests.swift
@@ -1,18 +1,21 @@
import Foundation
+import GoogleGenerativeAI
import XCTest
@testable import OpenAIService
-class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase {
+class GoogleAIChatCompletionsAPITests: XCTestCase {
+ let convert = GoogleAIChatCompletionsService.convertMessages
+
func test_top_system_prompt_should_convert_to_user_message_that_does_not_merge_with_others() {
- let prompt = ChatGPTPrompt(history: [
+ let prompt: [ChatCompletionsRequestBody.Message] = [
.init(role: .system, content: "SystemPrompt"),
.init(role: .user, content: "A"),
.init(role: .assistant, content: "B"),
.init(role: .user, content: "Hello"),
- ]).googleAICompatible
+ ]
- let expected = ChatGPTPrompt(history: [
+ let expected: [ChatCompletionsRequestBody.Message] = [
.init(role: .user, content: """
System Prompt:
SystemPrompt
@@ -21,14 +24,22 @@ class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase {
.init(role: .user, content: "A"),
.init(role: .assistant, content: "B"),
.init(role: .user, content: "Hello"),
- ])
-
- XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content))
- XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role))
+ ]
+
+ let converted = convert(prompt)
+
+ XCTAssertEqual(
+ converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } },
+ expected.map(\.content)
+ )
+ XCTAssertEqual(
+ converted.map(\.role),
+ expected.map(\.role).map(ModelContent.convertRole(_:))
+ )
}
func test_adjacent_same_role_messages_should_be_merged_except_for_the_last_user_message() {
- let prompt = ChatGPTPrompt(history: [
+ let prompt: [ChatCompletionsRequestBody.Message] = [
.init(role: .system, content: "SystemPrompt"),
.init(role: .user, content: "A"),
.init(role: .user, content: "B"),
@@ -37,9 +48,9 @@ class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase {
.init(role: .assistant, content: "E"),
.init(role: .assistant, content: "F"),
.init(role: .user, content: "World"),
- ]).googleAICompatible
+ ]
- let expected = ChatGPTPrompt(history: [
+ let expected: [ChatCompletionsRequestBody.Message] = [
.init(role: .user, content: """
System Prompt:
SystemPrompt
@@ -68,21 +79,29 @@ class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase {
F
"""),
.init(role: .user, content: "World"),
- ])
-
- XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content))
- XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role))
+ ]
+
+ let converted = convert(prompt)
+
+ XCTAssertEqual(
+ converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } },
+ expected.map(\.content)
+ )
+ XCTAssertEqual(
+ converted.map(\.role),
+ expected.map(\.role).map(ModelContent.convertRole(_:))
+ )
}
func test_non_top_system_prompt_should_merge_as_user_prompt() {
- let prompt = ChatGPTPrompt(history: [
+ let prompt: [ChatCompletionsRequestBody.Message] = [
.init(role: .user, content: "A"),
.init(role: .system, content: "SystemPrompt"),
.init(role: .assistant, content: "B"),
.init(role: .user, content: "Hello"),
- ]).googleAICompatible
+ ]
- let expected = ChatGPTPrompt(history: [
+ let expected: [ChatCompletionsRequestBody.Message] = [
.init(role: .user, content: """
A
@@ -93,46 +112,55 @@ class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase {
"""),
.init(role: .assistant, content: "B"),
.init(role: .user, content: "Hello"),
- ])
-
- XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content))
- XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role))
+ ]
+
+ let converted = convert(prompt)
+
+ XCTAssertEqual(
+ converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } },
+ expected.map(\.content)
+ )
+ XCTAssertEqual(
+ converted.map(\.role),
+ expected.map(\.role).map(ModelContent.convertRole(_:))
+ )
}
func test_function_call_should_convert_assistant_and_user_message_with_text_content() {
- let prompt = ChatGPTPrompt(history: [
+ let prompt: [ChatCompletionsRequestBody.Message] = [
.init(role: .user, content: "A"),
.init(
role: .assistant,
- content: nil,
+ content: "",
toolCalls: [
.init(
id: "id",
type: "function",
- function: .init(name: "ping", arguments: "{ \"ip\": \"127.0.0.1\" }"),
- response: .init(content: "42ms", summary: nil)
+ function: .init(name: "ping", arguments: "{ \"ip\": \"127.0.0.1\" }")
),
]
),
+ .init(role: .tool, content: "42ms", toolCallId: "id"),
.init(role: .assistant, content: "Merge me"),
.init(role: .user, content: "Merge me"),
.init(role: .user, content: "Merge me"),
.init(role: .assistant, content: "B"),
.init(role: .user, content: "Hello"),
- ]).googleAICompatible
+ ]
- let expected = ChatGPTPrompt(history: [
+ let expected: [ChatCompletionsRequestBody.Message] = [
.init(role: .user, content: "A"),
.init(role: .assistant, content: """
+ Function ID: id
Call function: ping
Arguments: { "ip": "127.0.0.1" }
- Result: 42ms
-
- ======
-
- Merge me
"""),
.init(role: .user, content: """
+ Result of function ID: id
+ 42ms
+ """),
+ .init(role: .assistant, content: "Merge me"),
+ .init(role: .user, content: """
Merge me
======
@@ -141,26 +169,42 @@ class ReformatPromptToBeGoogleAICompatibleTests: XCTestCase {
"""),
.init(role: .assistant, content: "B"),
.init(role: .user, content: "Hello"),
- ])
-
- XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content))
- XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role))
+ ]
+
+ let converted = convert(prompt)
+
+ XCTAssertEqual(
+ converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } },
+ expected.map(\.content)
+ )
+ XCTAssertEqual(
+ converted.map(\.role),
+ expected.map(\.role).map(ModelContent.convertRole(_:))
+ )
}
func test_if_the_second_last_message_is_from_user_add_a_dummy() {
- let prompt = ChatGPTPrompt(history: [
+ let prompt: [ChatCompletionsRequestBody.Message] = [
.init(role: .user, content: "A"),
.init(role: .user, content: "Hello"),
- ]).googleAICompatible
+ ]
- let expected = ChatGPTPrompt(history: [
+ let expected: [ChatCompletionsRequestBody.Message] = [
.init(role: .user, content: "A"),
.init(role: .assistant, content: "OK"),
.init(role: .user, content: "Hello"),
- ])
-
- XCTAssertEqual(prompt.history.map(\.content), expected.history.map(\.content))
- XCTAssertEqual(prompt.history.map(\.role), expected.history.map(\.role))
+ ]
+
+ let converted = convert(prompt)
+
+ XCTAssertEqual(
+ converted.map { $0.parts.reduce("") { $0 + ($1.text ?? "") } },
+ expected.map(\.content)
+ )
+ XCTAssertEqual(
+ converted.map(\.role),
+ expected.map(\.role).map(ModelContent.convertRole(_:))
+ )
}
}
diff --git a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift
index 3d47d6dc..da9cd557 100644
--- a/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift
+++ b/Tool/Tests/OpenAIServiceTests/LimitMessagesTests.swift
@@ -26,7 +26,7 @@ final class AutoManagedChatGPTMemoryLimitTests: XCTestCase {
])
// XCTAssertEqual(remainingTokens, 10000 - 12 - 6)
- let history = await memory.history
+// let history = await memory.history
// token count caching is removed
// XCTAssertEqual(history.map(\.tokensCount), [
@@ -131,7 +131,8 @@ private func runService(
let memory = AutoManagedChatGPTMemory(
systemPrompt: systemPrompt,
configuration: configuration,
- functionProvider: NoChatGPTFunctionProvider()
+ functionProvider: NoChatGPTFunctionProvider(),
+ maxNumberOfMessages: maxNumberOfMessages
)
for message in messages {
@@ -139,7 +140,6 @@ private func runService(
}
let messages = await memory.generateSendingHistory(
- maxNumberOfMessages: maxNumberOfMessages,
strategy: MockStrategy()
)
diff --git a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift b/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift
similarity index 61%
rename from Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift
rename to Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift
index 1f93b021..f8f8b909 100644
--- a/Core/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift
+++ b/Tool/Tests/SuggestionInjectorTests/AcceptSuggestionTests.swift
@@ -21,7 +21,8 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 1, character: 0),
end: .init(line: 1, character: 0)
- )
+ ),
+ replacingLines: "".breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
var lines = content.breakIntoEditorStyleLines()
@@ -34,7 +35,13 @@ final class AcceptSuggestionTests: XCTestCase {
)
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 1, character: 0),
+ end: .init(line: 2, character: 19)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 2, character: 19))
XCTAssertEqual(
@@ -67,7 +74,8 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 0, character: 0),
end: .init(line: 0, character: 12)
- )
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -81,7 +89,13 @@ final class AcceptSuggestionTests: XCTestCase {
)
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 0, character: 0),
+ end: .init(line: 2, character: 19)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 2, character: 19))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -110,7 +124,8 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 1, character: 0),
end: .init(line: 1, character: 12)
- )
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -124,7 +139,13 @@ final class AcceptSuggestionTests: XCTestCase {
)
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 1, character: 0),
+ end: .init(line: 2, character: 19)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 2, character: 19))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -153,7 +174,12 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 1, character: 0),
end: .init(line: 1, character: 12)
- )
+ ),
+ replacingLines: """
+ struct Cat {
+ var name
+ }
+ """.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -167,7 +193,13 @@ final class AcceptSuggestionTests: XCTestCase {
)
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 1, character: 0),
+ end: .init(line: 2, character: 19)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 2, character: 19))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -182,18 +214,21 @@ final class AcceptSuggestionTests: XCTestCase {
func test_accept_suggestion_overlap_continue_typing_has_suffix_typed() async throws {
let content = """
print("")
- """
+ """ // typed ")
let text = """
print("Hello World!")
"""
let suggestion = CodeSuggestion(
id: "",
text: text,
- position: .init(line: 0, character: 6),
+ position: .init(line: 0, character: 7),
range: .init(
start: .init(line: 0, character: 0),
- end: .init(line: 0, character: 6)
- )
+ end: .init(line: 0, character: 7)
+ ),
+ replacingLines: """
+ print("
+ """.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -207,7 +242,13 @@ final class AcceptSuggestionTests: XCTestCase {
)
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 0, character: 0),
+ end: .init(line: 0, character: 21)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 0, character: 21))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -226,11 +267,14 @@ final class AcceptSuggestionTests: XCTestCase {
let suggestion = CodeSuggestion(
id: "",
text: text,
- position: .init(line: 0, character: 6),
+ position: .init(line: 0, character: 7),
range: .init(
start: .init(line: 0, character: 0),
- end: .init(line: 0, character: 6)
- )
+ end: .init(line: 0, character: 7)
+ ),
+ replacingLines: """
+ print("")
+ """.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -244,7 +288,13 @@ final class AcceptSuggestionTests: XCTestCase {
)
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 0, character: 0),
+ end: .init(line: 0, character: 19)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 0, character: 19))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -271,7 +321,10 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 0, character: 0),
end: .init(line: 0, character: 6)
- )
+ ),
+ replacingLines: """
+ struct
+ """.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -285,7 +338,13 @@ final class AcceptSuggestionTests: XCTestCase {
)
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 0, character: 0),
+ end: .init(line: 3, character: 1)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 3, character: 1))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -315,7 +374,8 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 0, character: 0),
end: .init(line: 0, character: 20)
- )
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -329,7 +389,13 @@ final class AcceptSuggestionTests: XCTestCase {
)
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 0, character: 0),
+ end: .init(line: 6, character: 1)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 6, character: 1))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -361,7 +427,8 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 1, character: 0),
end: .init(line: 1, character: 0)
- )
+ ),
+ replacingLines: "".breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -375,7 +442,13 @@ final class AcceptSuggestionTests: XCTestCase {
)
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 1, character: 0),
+ end: .init(line: 6, character: 1)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 6, character: 1))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -410,7 +483,8 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 0, character: 0),
end: .init(line: 2, character: 1)
- )
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -425,7 +499,13 @@ final class AcceptSuggestionTests: XCTestCase {
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 0, character: 0),
+ end: .init(line: 4, character: 1)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 4, character: 1))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -463,7 +543,8 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 4, character: 7),
end: .init(line: 5, character: 34)
- )
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -478,7 +559,13 @@ final class AcceptSuggestionTests: XCTestCase {
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 4, character: 7),
+ end: .init(line: 7, character: 5)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 7, character: 5))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -510,7 +597,8 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 0, character: 0),
end: .init(line: 0, character: 12)
- )
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
)
var lines = content.breakIntoEditorStyleLines()
@@ -529,7 +617,7 @@ final class AcceptSuggestionTests: XCTestCase {
""")
}
-
+
func test_remove_the_first_adjacent_placeholder_in_the_last_line(
) async throws {
let content = """
@@ -543,7 +631,8 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 0, character: 0),
end: .init(line: 0, character: 12)
- )
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
)
var lines = content.breakIntoEditorStyleLines()
@@ -562,7 +651,7 @@ final class AcceptSuggestionTests: XCTestCase {
""")
}
-
+
func test_accept_suggestion_start_from_previous_line_has_emoji_inside() async throws {
let content = """
struct 😹😹 {
@@ -580,7 +669,8 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 0, character: 0),
end: .init(line: 0, character: 13)
- )
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -594,7 +684,13 @@ final class AcceptSuggestionTests: XCTestCase {
)
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 0, character: 0),
+ end: .init(line: 2, character: 19)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 2, character: 19))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -605,7 +701,7 @@ final class AcceptSuggestionTests: XCTestCase {
""")
}
-
+
func test_accept_suggestion_overlap_with_emoji_in_the_previous_code() async throws {
let content = """
struct 😹😹 {
@@ -623,7 +719,8 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 1, character: 0),
end: .init(line: 1, character: 13)
- )
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -637,7 +734,13 @@ final class AcceptSuggestionTests: XCTestCase {
)
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 1, character: 0),
+ end: .init(line: 2, character: 19)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 2, character: 19))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -648,7 +751,7 @@ final class AcceptSuggestionTests: XCTestCase {
""")
}
-
+
func test_accept_suggestion_overlap_continue_typing_has_emoji_inside() async throws {
let content = """
struct 😹😹 {
@@ -666,7 +769,12 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 1, character: 0),
end: .init(line: 1, character: 13)
- )
+ ),
+ replacingLines: """
+ struct 😹😹 {
+ var name:
+ }
+ """.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -680,7 +788,13 @@ final class AcceptSuggestionTests: XCTestCase {
)
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 1, character: 0),
+ end: .init(line: 2, character: 19)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 2, character: 19))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -691,7 +805,7 @@ final class AcceptSuggestionTests: XCTestCase {
""")
}
-
+
func test_replacing_multiple_lines_with_emoji() async throws {
let content = """
struct 😹😹 {
@@ -712,7 +826,8 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 0, character: 0),
end: .init(line: 2, character: 1)
- )
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -727,7 +842,13 @@ final class AcceptSuggestionTests: XCTestCase {
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 0, character: 0),
+ end: .init(line: 4, character: 1)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 4, character: 1))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -739,8 +860,9 @@ final class AcceptSuggestionTests: XCTestCase {
""")
}
-
- func test_accept_suggestion_overlap_continue_typing_suggestion_with_emoji_in_the_middle() async throws {
+
+ func test_accept_suggestion_overlap_continue_typing_suggestion_with_emoji_in_the_middle(
+ ) async throws {
let content = """
print("🐶")
"""
@@ -754,7 +876,10 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 0, character: 0),
end: .init(line: 0, character: 6)
- )
+ ),
+ replacingLines: """
+ print(")
+ """.breakLines(appendLineBreakToLastLine: true)
)
var extraInfo = SuggestionInjector.ExtraInfo()
@@ -768,7 +893,13 @@ final class AcceptSuggestionTests: XCTestCase {
)
XCTAssertTrue(extraInfo.didChangeContent)
XCTAssertTrue(extraInfo.didChangeCursorPosition)
- XCTAssertNil(extraInfo.suggestionRange)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 0, character: 0),
+ end: .init(line: 0, character: 19)),
+ ]
+ )
XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
XCTAssertEqual(cursor, .init(line: 0, character: 19))
XCTAssertEqual(lines.joined(separator: ""), """
@@ -776,7 +907,7 @@ final class AcceptSuggestionTests: XCTestCase {
""")
}
-
+
func test_replacing_single_line_in_the_middle_should_not_remove_the_next_character_with_emoji(
) async throws {
let content = """
@@ -790,7 +921,8 @@ final class AcceptSuggestionTests: XCTestCase {
range: .init(
start: .init(line: 0, character: 0),
end: .init(line: 0, character: 11)
- )
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
)
var lines = content.breakIntoEditorStyleLines()
@@ -809,6 +941,301 @@ final class AcceptSuggestionTests: XCTestCase {
""")
}
+
+ func test_accept_suggestion_in_the_middle_single_line() async throws {
+ let content = """
+ let foobar = 1
+ """
+ let text = """
+ let fooBar
+ """
+ let suggestion = CodeSuggestion(
+ id: "",
+ text: text,
+ position: .init(line: 0, character: 7),
+ range: .init(
+ start: .init(line: 0, character: 0),
+ end: .init(line: 0, character: 10)
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
+ )
+
+ var extraInfo = SuggestionInjector.ExtraInfo()
+ var lines = content.breakIntoEditorStyleLines()
+ var cursor = CursorPosition(line: 0, character: 7)
+ SuggestionInjector().acceptSuggestion(
+ intoContentWithoutSuggestion: &lines,
+ cursorPosition: &cursor,
+ completion: suggestion,
+ extraInfo: &extraInfo
+ )
+ XCTAssertTrue(extraInfo.didChangeContent)
+ XCTAssertTrue(extraInfo.didChangeCursorPosition)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 0, character: 0),
+ end: .init(line: 0, character: 10)),
+ ]
+ )
+ XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
+ XCTAssertEqual(cursor, .init(line: 0, character: 10))
+ XCTAssertEqual(lines.joined(separator: ""), """
+ let fooBar = 1
+
+ """)
+ }
+
+ func test_accept_suggestion_in_the_middle_single_line_case_2() async throws {
+ let content = """
+ let pikachecker = 1
+ """
+ let text = """
+ let pikaChecker
+ """
+ let suggestion = CodeSuggestion(
+ id: "",
+ text: text,
+ position: .init(line: 0, character: 16),
+ range: .init(
+ start: .init(line: 0, character: 0),
+ end: .init(line: 0, character: 23)
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
+ )
+
+ var extraInfo = SuggestionInjector.ExtraInfo()
+ var lines = content.breakIntoEditorStyleLines()
+ var cursor = CursorPosition(line: 0, character: 16)
+ SuggestionInjector().acceptSuggestion(
+ intoContentWithoutSuggestion: &lines,
+ cursorPosition: &cursor,
+ completion: suggestion,
+ extraInfo: &extraInfo
+ )
+ XCTAssertTrue(extraInfo.didChangeContent)
+ XCTAssertTrue(extraInfo.didChangeCursorPosition)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 0, character: 0),
+ end: .init(line: 0, character: 23)),
+ ]
+ )
+ XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
+ XCTAssertEqual(cursor, .init(line: 0, character: 23))
+ XCTAssertEqual(lines.joined(separator: ""), """
+ let pikaChecker = 1
+
+ """)
+ }
+
+ func test_accept_suggestion_rewriting_the_single_line() async throws {
+ let content = """
+ let foobar =
+ """
+ let text = """
+ let zooKoo = 2
+ """
+ let suggestion = CodeSuggestion(
+ id: "",
+ text: text,
+ position: .init(line: 0, character: 12),
+ range: .init(
+ start: .init(line: 0, character: 0),
+ end: .init(line: 0, character: 12)
+ ),
+ replacingLines: content.breakLines(appendLineBreakToLastLine: true)
+ )
+
+ var extraInfo = SuggestionInjector.ExtraInfo()
+ var lines = content.breakIntoEditorStyleLines()
+ var cursor = CursorPosition(line: 0, character: 7)
+ SuggestionInjector().acceptSuggestion(
+ intoContentWithoutSuggestion: &lines,
+ cursorPosition: &cursor,
+ completion: suggestion,
+ extraInfo: &extraInfo
+ )
+ XCTAssertTrue(extraInfo.didChangeContent)
+ XCTAssertTrue(extraInfo.didChangeCursorPosition)
+ XCTAssertEqual(
+ extraInfo.modificationRanges,
+ [
+ "": CursorRange(start: .init(line: 0, character: 0),
+ end: .init(line: 0, character: 14)),
+ ]
+ )
+ XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
+ XCTAssertEqual(cursor, .init(line: 0, character: 14))
+ XCTAssertEqual(lines.joined(separator: ""), """
+ let zooKoo = 2
+
+ """)
+ }
+
+ func test_accepting_multiple_suggestions_at_a_time() async throws {
+ let content = """
+ protocol Definition {
+ var id: String
+ var name: String
+ }
+
+ struct Foo {
+
+ }
+
+ struct Bar {
+
+ }
+
+ let foo = Foo()
+
+ struct Baz {}
+ """
+ let text1 = """
+ struct Foo: Definition {
+ var id: String
+ var name: String
+ }
+ """
+ let suggestion1 = CodeSuggestion(
+ id: "1",
+ text: text1,
+ position: .init(line: 5, character: 0),
+ range: .init(
+ start: .init(line: 5, character: 0),
+ end: .init(line: 7, character: 1)
+ ),
+ replacingLines: Array(content.breakLines(appendLineBreakToLastLine: true)[5...7])
+ )
+
+ let text2 = """
+ struct Bar: Definition {
+ var id: String
+ var name: String
+ }
+ """
+ let suggestion2 = CodeSuggestion(
+ id: "2",
+ text: text2,
+ position: .init(line: 9, character: 0),
+ range: .init(
+ start: .init(line: 9, character: 0),
+ end: .init(line: 11, character: 1)
+ ),
+ replacingLines: Array(content.breakLines(appendLineBreakToLastLine: true)[9...11])
+ )
+
+ let text3 = """
+ struct Baz: Definition {
+ var id: String
+ var name: String
+ }
+ """
+ let suggestion3 = CodeSuggestion(
+ id: "3",
+ text: text3,
+ position: .init(line: 15, character: 0),
+ range: .init(
+ start: .init(line: 15, character: 0),
+ end: .init(line: 15, character: 13)
+ ),
+ replacingLines: Array(content.breakLines(appendLineBreakToLastLine: true)[15...15])
+ )
+
+ var extraInfo = SuggestionInjector.ExtraInfo()
+ var lines = content.breakIntoEditorStyleLines()
+ var cursor = CursorPosition(line: 0, character: 14)
+ SuggestionInjector().acceptSuggestions(
+ intoContentWithoutSuggestion: &lines,
+ cursorPosition: &cursor,
+ completions: [suggestion1, suggestion2, suggestion3],
+ extraInfo: &extraInfo
+ )
+ XCTAssertTrue(extraInfo.didChangeContent)
+ XCTAssertTrue(extraInfo.didChangeCursorPosition)
+ XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
+ XCTAssertEqual(cursor, .init(line: 20, character: 1))
+ XCTAssertEqual(lines.joined(separator: ""), """
+ protocol Definition {
+ var id: String
+ var name: String
+ }
+
+ struct Foo: Definition {
+ var id: String
+ var name: String
+ }
+
+ struct Bar: Definition {
+ var id: String
+ var name: String
+ }
+
+ let foo = Foo()
+
+ struct Baz: Definition {
+ var id: String
+ var name: String
+ }
+
+ """)
+ XCTAssertEqual(extraInfo.modificationRanges, [
+ "1": .init(start: .init(line: 5, character: 0), end: .init(line: 8, character: 1)),
+ "2": .init(start: .init(line: 10, character: 0), end: .init(line: 13, character: 1)),
+ "3": .init(start: .init(line: 17, character: 0), end: .init(line: 20, character: 1)),
+ ])
+ }
+
+// Not supported yet
+// func test_accepting_multiple_same_line_suggestions_at_a_time() async throws {
+// let content = "let foo = 1\n"
+// let text1 = "berry"
+// let suggestion1 = CodeSuggestion(
+// id: "1",
+// text: text1,
+// position: .init(line: 0, character: 4),
+// range: .init(
+// start: .init(line: 0, character: 4),
+// end: .init(line: 0, character: 7)
+// ),
+// replacingLines: [content]
+// )
+//
+// let text2 = """
+// 200
+// """
+// let suggestion2 = CodeSuggestion(
+// id: "2",
+// text: text2,
+// position: .init(line: 0, character: 10),
+// range: .init(
+// start: .init(line: 0, character: 10),
+// end: .init(line: 0, character: 11)
+// ),
+// replacingLines: [content]
+// )
+//
+// var extraInfo = SuggestionInjector.ExtraInfo()
+// var lines = content.breakIntoEditorStyleLines()
+// var cursor = CursorPosition(line: 0, character: 0)
+// SuggestionInjector().acceptSuggestions(
+// intoContentWithoutSuggestion: &lines,
+// cursorPosition: &cursor,
+// completions: [suggestion1, suggestion2],
+// extraInfo: &extraInfo
+// )
+// XCTAssertTrue(extraInfo.didChangeContent)
+// XCTAssertTrue(extraInfo.didChangeCursorPosition)
+// XCTAssertEqual(lines, content.breakIntoEditorStyleLines().applying(extraInfo.modifications))
+// XCTAssertEqual(cursor, .init(line: 0, character: 15))
+// XCTAssertEqual(lines.joined(separator: ""), "let berry = 200\n")
+// XCTAssertEqual(extraInfo.modificationRanges, [
+// "1": .init(start: .init(line: 0, character: 4), end: .init(line: 0, character: 9)),
+// "2": .init(start: .init(line: 0, character: 12), end: .init(line: 0, character: 15)),
+// ])
+// }
}
extension String {
diff --git a/Core/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift b/Tool/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift
similarity index 100%
rename from Core/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift
rename to Tool/Tests/SuggestionInjectorTests/ProposeSuggestionTests.swift
diff --git a/Core/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift b/Tool/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift
similarity index 100%
rename from Core/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift
rename to Tool/Tests/SuggestionInjectorTests/RejectSuggestionTests.swift
diff --git a/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift b/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift
index 3ed29251..214e6ad8 100644
--- a/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift
+++ b/Tool/Tests/SuggestionProviderTests/PostProcessingSuggestionServiceMiddlewareTests.swift
@@ -241,7 +241,8 @@ class PostProcessingSuggestionServiceMiddlewareTests: XCTestCase {
id: "1",
text: "hello world",
position: .init(line: 0, character: 1),
- range: .init(startPair: (0, 0), endPair: (0, 1))
+ range: .init(startPair: (0, 0), endPair: (0, 1)),
+ middlewareComments: ["Removed redundant closing parenthesis."]
),
])
}
@@ -275,7 +276,8 @@ class PostProcessingSuggestionServiceMiddlewareTests: XCTestCase {
id: "1",
text: "",
position: .init(line: 0, character: 0),
- range: .init(startPair: (0, 0), endPair: (0, 0))
+ range: .init(startPair: (0, 0), endPair: (0, 0)),
+ middlewareComments: ["Removed redundant closing parenthesis."]
),
])
}
@@ -309,7 +311,8 @@ class PostProcessingSuggestionServiceMiddlewareTests: XCTestCase {
id: "1",
text: "hello world",
position: .init(line: 0, character: 1),
- range: .init(startPair: (0, 0), endPair: (0, 1))
+ range: .init(startPair: (0, 0), endPair: (0, 1)),
+ middlewareComments: ["Removed redundant closing parenthesis."]
),
])
}
@@ -343,7 +346,8 @@ class PostProcessingSuggestionServiceMiddlewareTests: XCTestCase {
id: "1",
text: "hello world",
position: .init(line: 0, character: 1),
- range: .init(startPair: (0, 0), endPair: (0, 1))
+ range: .init(startPair: (0, 0), endPair: (0, 1)),
+ middlewareComments: ["Removed redundant closing parenthesis."]
),
])
}
@@ -377,7 +381,8 @@ class PostProcessingSuggestionServiceMiddlewareTests: XCTestCase {
id: "1",
text: "hello world",
position: .init(line: 0, character: 1),
- range: .init(startPair: (0, 0), endPair: (0, 1))
+ range: .init(startPair: (0, 0), endPair: (0, 1)),
+ middlewareComments: ["Removed redundant closing parenthesis."]
),
])
}
diff --git a/Version.xcconfig b/Version.xcconfig
index 10eba9dc..6b4ae4b3 100644
--- a/Version.xcconfig
+++ b/Version.xcconfig
@@ -1,3 +1,3 @@
-APP_VERSION = 0.33.8
-APP_BUILD = 402
+APP_VERSION = 0.34.0
+APP_BUILD = 412