From 71bdcf5f8f1e6356b41bcccecb17a79d9d32ffe8 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 13 Oct 2025 02:55:30 +0800 Subject: [PATCH 01/19] Update Package.swift --- Core/Package.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Core/Package.swift b/Core/Package.swift index fa46fdd0..3f5d92fb 100644 --- a/Core/Package.swift +++ b/Core/Package.swift @@ -64,8 +64,8 @@ let package = Package( .product(name: "SuggestionBasic", package: "Tool"), .product(name: "Logger", package: "Tool"), .product(name: "Preferences", package: "Tool"), - ].pro([ - "ProClient", + ].proCore([ + "LicenseManagement", ]) ), .target( @@ -348,7 +348,7 @@ extension [Target.Dependency] { extension [Package.Dependency] { var pro: [Package.Dependency] { if isProIncluded { - return self + [.package(path: "../../Pro")] + return self + [.package(path: "../../Pro"), .package(path: "../../Pro/ProCore")] } return self } From 78ba02311d207f0efb030122f3fbb19002f367b2 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 Oct 2025 00:35:34 +0800 Subject: [PATCH 02/19] Make ChatPlugin support string only response --- .../ShortcutChatPlugin.swift | 31 ++++- .../TerminalChatPlugin.swift | 127 +++++++++++------- Core/Sources/ChatService/AllPlugins.swift | 2 +- Tool/Sources/ChatBasic/ChatPlugin.swift | 31 ++++- 4 files changed, 137 insertions(+), 54 deletions(-) diff --git a/ChatPlugins/Sources/ShortcutChatPlugin/ShortcutChatPlugin.swift b/ChatPlugins/Sources/ShortcutChatPlugin/ShortcutChatPlugin.swift index 540a7365..fc9d8d5b 100644 --- a/ChatPlugins/Sources/ShortcutChatPlugin/ShortcutChatPlugin.swift +++ b/ChatPlugins/Sources/ShortcutChatPlugin/ShortcutChatPlugin.swift @@ -20,7 +20,36 @@ public final class ShortcutChatPlugin: ChatPlugin { terminal = Terminal() } - public func send(_ request: Request) async -> AsyncThrowingStream { + public func sendForTextResponse(_ request: Request) async + -> AsyncThrowingStream + { + let stream = await sendForComplicatedResponse(request) + return .init { continuation in + let task = Task { + do { + for try await response in stream { + switch response { + case let .content(.text(content)): + continuation.yield(content) + default: + break + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + + public func sendForComplicatedResponse(_ request: Request) async + -> AsyncThrowingStream + { return .init { continuation in let task = Task { let id = "\(Self.command)-\(UUID().uuidString)" diff --git a/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift index aca1c01c..6242735a 100644 --- a/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift +++ b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift @@ -9,7 +9,7 @@ public final class TerminalChatPlugin: ChatPlugin { public static var name: String { "Terminal" } public static var description: String { """ Run the command in the message from terminal. - + You can use environment variable `$FILE_PATH` and `$PROJECT_ROOT` to access the current file path and project root. """ } @@ -23,41 +23,11 @@ public final class TerminalChatPlugin: ChatPlugin { terminal = Terminal() } - public func formatContent(_ content: Response.Content) -> Response.Content { - switch content { - case let .text(content): - return .text(""" - ```sh - \(content) - ``` - """) - } - } - - public func send(_ request: Request) async -> AsyncThrowingStream { + public func sendForTextResponse(_ request: Request) async + -> AsyncThrowingStream + { return .init { continuation in let task = Task { - var updateTime = Date() - - func streamOutput(_ content: String) { - defer { updateTime = Date() } - if Date().timeIntervalSince(updateTime) > 60 * 2 { - continuation.yield(.startNewMessage) - continuation.yield(.startAction( - id: "run", - task: "Continue `\(request.text)`" - )) - continuation.yield(.finishAction( - id: "run", - result: .success("Executed.") - )) - continuation.yield(.content(.text("[continue]\n"))) - continuation.yield(.content(.text(content))) - } else { - continuation.yield(.content(.text(content))) - } - } - do { let fileURL = XcodeInspector.shared.realtimeActiveDocumentURL let projectURL = XcodeInspector.shared.realtimeActiveProjectURL @@ -75,8 +45,6 @@ public final class TerminalChatPlugin: ChatPlugin { let env = ProcessInfo.processInfo.environment let shell = env["SHELL"] ?? "/bin/bash" - continuation.yield(.startAction(id: "run", task: "Run `\(request.text)`")) - let output = terminal.streamCommand( shell, arguments: ["-i", "-l", "-c", request.text], @@ -84,25 +52,18 @@ public final class TerminalChatPlugin: ChatPlugin { environment: environment ) - continuation.yield(.finishAction( - id: "run", - result: .success("Executed.") - )) - + var accumulatedOutput = "" for try await content in output { try Task.checkCancellation() - streamOutput(content) + accumulatedOutput += content + continuation.yield(accumulatedOutput) } } catch let error as Terminal.TerminationError { - continuation.yield(.content(.text(""" - - [error: \(error.reason)] - """))) + let errorMessage = "\n\n[error: \(error.reason)]" + continuation.yield(errorMessage) } catch { - continuation.yield(.content(.text(""" - - [error: \(error.localizedDescription)] - """))) + let errorMessage = "\n\n[error: \(error.localizedDescription)]" + continuation.yield(errorMessage) } continuation.finish() @@ -116,5 +77,71 @@ public final class TerminalChatPlugin: ChatPlugin { } } } + + public func formatContent(_ content: Response.Content) -> Response.Content { + switch content { + case let .text(content): + return .text(""" + ```sh + \(content) + ``` + """) + } + } + + public func sendForComplicatedResponse(_ request: Request) async + -> AsyncThrowingStream + { + return .init { continuation in + let task = Task { + var updateTime = Date() + + continuation.yield(.startAction(id: "run", task: "Run `\(request.text)`")) + + let textStream = await sendForTextResponse(request) + var previousOutput = "" + + continuation.yield(.finishAction( + id: "run", + result: .success("Executed.") + )) + + do { + for try await accumulatedOutput in textStream { + try Task.checkCancellation() + + let newContent = accumulatedOutput.dropFirst(previousOutput.count) + previousOutput = accumulatedOutput + + if !newContent.isEmpty { + if Date().timeIntervalSince(updateTime) > 60 * 2 { + continuation.yield(.startNewMessage) + continuation.yield(.startAction( + id: "run", + task: "Continue `\(request.text)`" + )) + continuation.yield(.finishAction( + id: "run", + result: .success("Executed.") + )) + continuation.yield(.content(.text("[continue]\n"))) + updateTime = Date() + } + + continuation.yield(.content(.text(String(newContent)))) + } + } + + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } } diff --git a/Core/Sources/ChatService/AllPlugins.swift b/Core/Sources/ChatService/AllPlugins.swift index 82e756ec..3f0b9de7 100644 --- a/Core/Sources/ChatService/AllPlugins.swift +++ b/Core/Sources/ChatService/AllPlugins.swift @@ -56,7 +56,7 @@ final class LegacyChatPluginWrapper: LegacyChatPlugin { let plugin = Plugin() - let stream = await plugin.send(.init( + let stream = await plugin.sendForComplicatedResponse(.init( text: content, arguments: [], history: chatGPTService.memory.history diff --git a/Tool/Sources/ChatBasic/ChatPlugin.swift b/Tool/Sources/ChatBasic/ChatPlugin.swift index 1e7eb576..e6e0ef91 100644 --- a/Tool/Sources/ChatBasic/ChatPlugin.swift +++ b/Tool/Sources/ChatBasic/ChatPlugin.swift @@ -19,7 +19,13 @@ public protocol ChatPlugin { static var command: String { get } static var name: String { get } static var description: String { get } - func send(_ request: Request) async -> AsyncThrowingStream + // In this method, the plugin is able to send more complicated response. It also enables it to + // perform special tasks like starting a new message or reporting progress. + func sendForComplicatedResponse( + _ request: Request + ) async -> AsyncThrowingStream + // This method allows the plugin to respond a stream of text content only. + func sendForTextResponse(_ request: Request) async -> AsyncThrowingStream func formatContent(_ content: Response.Content) -> Response.Content init() } @@ -28,5 +34,26 @@ public extension ChatPlugin { func formatContent(_ content: Response.Content) -> Response.Content { return content } + + func sendForComplicatedResponse( + _ request: Request + ) async -> AsyncThrowingStream { + let textStream = await sendForTextResponse(request) + return AsyncThrowingStream { continuation in + let task = Task { + do { + for try await text in textStream { + continuation.yield(Response.content(.text(text))) + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } } - From 4129018ead949879564af191bd9f242e80c52405 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 Oct 2025 00:35:42 +0800 Subject: [PATCH 03/19] Make ChatMessage Sendable --- Tool/Sources/ChatBasic/ChatMessage.swift | 31 ++++++++++++------------ 1 file changed, 16 insertions(+), 15 deletions(-) diff --git a/Tool/Sources/ChatBasic/ChatMessage.swift b/Tool/Sources/ChatBasic/ChatMessage.swift index 033e3578..74aace10 100644 --- a/Tool/Sources/ChatBasic/ChatMessage.swift +++ b/Tool/Sources/ChatBasic/ChatMessage.swift @@ -1,12 +1,12 @@ -import CodableWrappers +@preconcurrency import CodableWrappers import Foundation /// A chat message that can be sent or received. -public struct ChatMessage: Equatable, Codable { +public struct ChatMessage: Equatable, Codable, Sendable { public typealias ID = String /// The role of a message. - public enum Role: String, Codable, Equatable { + public enum Role: String, Codable, Equatable, Sendable { case system case user case assistant @@ -15,7 +15,7 @@ public struct ChatMessage: Equatable, Codable { } /// A function call that can be made by the bot. - public struct FunctionCall: Codable, Equatable { + public struct FunctionCall: Codable, Equatable, Sendable { /// The name of the function. public var name: String /// Arguments in the format of a JSON string. @@ -27,7 +27,7 @@ public struct ChatMessage: Equatable, Codable { } /// A tool call that can be made by the bot. - public struct ToolCall: Codable, Equatable, Identifiable { + public struct ToolCall: Codable, Equatable, Identifiable, Sendable { public var id: String /// The type of tool call. public var type: String @@ -49,7 +49,7 @@ public struct ChatMessage: Equatable, Codable { } /// The response of a tool call - public struct ToolCallResponse: Codable, Equatable { + public struct ToolCallResponse: Codable, Equatable, Sendable { /// The content of the response. public var content: String /// The summary of the response to display in UI. @@ -61,10 +61,10 @@ public struct ChatMessage: Equatable, Codable { } /// A reference to include in a chat message. - public struct Reference: Codable, Equatable, Identifiable { + public struct Reference: Codable, Equatable, Identifiable, Sendable { /// The kind of reference. - public enum Kind: Codable, Equatable { - public enum Symbol: String, Codable { + public enum Kind: Codable, Equatable, Sendable { + public enum Symbol: String, Codable, Sendable { case `class` case `struct` case `enum` @@ -77,6 +77,7 @@ public struct ChatMessage: Equatable, Codable { case function case method } + /// Code symbol. case symbol(Symbol, uri: String, startLine: Int?, endLine: Int?) /// Some text. @@ -138,10 +139,10 @@ 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? @@ -151,7 +152,7 @@ public struct ChatMessage: Equatable, Codable { /// The references of this message. @FallbackDecoding> public var references: [Reference] - + /// Cache the message in the prompt if possible. public var cacheIfPossible: Bool @@ -190,15 +191,15 @@ public struct ChatMessage: Equatable, Codable { } } -public struct ReferenceKindFallback: FallbackValueProvider { +public struct ReferenceKindFallback: FallbackValueProvider, Sendable { public static var defaultValue: ChatMessage.Reference.Kind { .other(kind: "Unknown") } } -public struct ReferenceIDFallback: FallbackValueProvider { +public struct ReferenceIDFallback: FallbackValueProvider, Sendable { public static var defaultValue: String { UUID().uuidString } } -public struct ChatMessageRoleFallback: FallbackValueProvider { +public struct ChatMessageRoleFallback: FallbackValueProvider, Sendable { public static var defaultValue: ChatMessage.Role { .user } } From 0157221ecafcf3923d47425618f916d0c4bea60e Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 16 Oct 2025 23:28:01 +0800 Subject: [PATCH 04/19] Update TerminalChatPlugin --- .../TerminalChatPlugin.swift | 78 ++++++++++++------- .../Views/ThemedMarkdownText.swift | 5 +- 2 files changed, 52 insertions(+), 31 deletions(-) diff --git a/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift index 6242735a..cf804ed5 100644 --- a/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift +++ b/ChatPlugins/Sources/TerminalChatPlugin/TerminalChatPlugin.swift @@ -23,8 +23,8 @@ public final class TerminalChatPlugin: ChatPlugin { terminal = Terminal() } - public func sendForTextResponse(_ request: Request) async - -> AsyncThrowingStream + public func getTextContent(from request: Request) async + -> AsyncStream { return .init { continuation in let task = Task { @@ -78,11 +78,33 @@ public final class TerminalChatPlugin: ChatPlugin { } } + public func sendForTextResponse(_ request: Request) async + -> AsyncThrowingStream + { + let stream = await getTextContent(from: request) + return .init { continuation in + let task = Task { + continuation.yield("Executing command: `\(request.text)`\n\n") + continuation.yield("```console\n") + for await text in stream { + try Task.checkCancellation() + continuation.yield(text) + } + continuation.yield("\n```\n") + continuation.finish() + } + + continuation.onTermination = { _ in + task.cancel() + } + } + } + public func formatContent(_ content: Response.Content) -> Response.Content { switch content { case let .text(content): return .text(""" - ```sh + ```console \(content) ``` """) @@ -98,7 +120,7 @@ public final class TerminalChatPlugin: ChatPlugin { continuation.yield(.startAction(id: "run", task: "Run `\(request.text)`")) - let textStream = await sendForTextResponse(request) + let textStream = await getTextContent(from: request) var previousOutput = "" continuation.yield(.finishAction( @@ -106,36 +128,32 @@ public final class TerminalChatPlugin: ChatPlugin { result: .success("Executed.") )) - do { - for try await accumulatedOutput in textStream { - try Task.checkCancellation() + for await accumulatedOutput in textStream { + try Task.checkCancellation() - let newContent = accumulatedOutput.dropFirst(previousOutput.count) - previousOutput = accumulatedOutput - - if !newContent.isEmpty { - if Date().timeIntervalSince(updateTime) > 60 * 2 { - continuation.yield(.startNewMessage) - continuation.yield(.startAction( - id: "run", - task: "Continue `\(request.text)`" - )) - continuation.yield(.finishAction( - id: "run", - result: .success("Executed.") - )) - continuation.yield(.content(.text("[continue]\n"))) - updateTime = Date() - } - - continuation.yield(.content(.text(String(newContent)))) + let newContent = accumulatedOutput.dropFirst(previousOutput.count) + previousOutput = accumulatedOutput + + if !newContent.isEmpty { + if Date().timeIntervalSince(updateTime) > 60 * 2 { + continuation.yield(.startNewMessage) + continuation.yield(.startAction( + id: "run", + task: "Continue `\(request.text)`" + )) + continuation.yield(.finishAction( + id: "run", + result: .success("Executed.") + )) + continuation.yield(.content(.text("[continue]\n"))) + updateTime = Date() } - } - continuation.finish() - } catch { - continuation.finish(throwing: error) + continuation.yield(.content(.text(String(newContent)))) + } } + + continuation.finish() } continuation.onTermination = { _ in diff --git a/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift index 1efa86f5..2811e4ad 100644 --- a/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift +++ b/Core/Sources/ChatGPTChatTab/Views/ThemedMarkdownText.swift @@ -71,7 +71,10 @@ extension MarkdownUI.Theme { } .codeBlock { configuration in let wrapCode = UserDefaults.shared.value(for: \.wrapCodeInChatCodeBlock) - || ["plaintext", "text", "markdown", "sh", "bash", "shell", "latex", "tex"] + || [ + "plaintext", "text", "markdown", "sh", "console", "bash", "shell", "latex", + "tex" + ] .contains(configuration.language) if wrapCode { From 3cae3be6691e8c777bc63e993d492c3138f89b07 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Mon, 20 Oct 2025 21:28:22 +0800 Subject: [PATCH 05/19] Make things Sendable --- Tool/Sources/ChatBasic/ChatGPTFunction.swift | 2 +- .../Sources/ChatContextCollector/ChatContextCollector.swift | 4 ++-- Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift | 6 +++--- .../OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift | 2 +- Tool/Sources/SuggestionBasic/EditorInformation.swift | 6 +++--- Tool/Sources/SuggestionBasic/ExportedFromLSP.swift | 2 +- .../SuggestionBasic/LanguageIdentifierFromFilePath.swift | 4 ++-- Tool/Sources/Workspace/Filespace.swift | 2 +- Tool/Sources/Workspace/Workspace.swift | 2 +- 9 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Tool/Sources/ChatBasic/ChatGPTFunction.swift b/Tool/Sources/ChatBasic/ChatGPTFunction.swift index 77913a2a..6824ff38 100644 --- a/Tool/Sources/ChatBasic/ChatGPTFunction.swift +++ b/Tool/Sources/ChatBasic/ChatGPTFunction.swift @@ -21,7 +21,7 @@ public protocol ChatGPTFunction { typealias NoArguments = NoChatGPTFunctionArguments associatedtype Arguments: Decodable associatedtype Result: ChatGPTFunctionResult - typealias ReportProgress = (String) async -> Void + typealias ReportProgress = @Sendable (String) async -> Void /// The name of this function. /// May contain a-z, A-Z, 0-9, and underscores, with a maximum length of 64 characters. diff --git a/Tool/Sources/ChatContextCollector/ChatContextCollector.swift b/Tool/Sources/ChatContextCollector/ChatContextCollector.swift index 3e45ed63..82d576c3 100644 --- a/Tool/Sources/ChatContextCollector/ChatContextCollector.swift +++ b/Tool/Sources/ChatContextCollector/ChatContextCollector.swift @@ -4,7 +4,7 @@ import OpenAIService import Parsing public struct ChatContext { - public enum Scope: String, Equatable, CaseIterable, Codable { + public enum Scope: String, Equatable, CaseIterable, Codable, Sendable { case file case code case sense @@ -12,7 +12,7 @@ public struct ChatContext { case web } - public struct RetrievedContent { + public struct RetrievedContent: Sendable { public var document: ChatMessage.Reference public var priority: Int diff --git a/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift index daa20e6b..817fe704 100644 --- a/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift +++ b/Tool/Sources/FocusedCodeFinder/ActiveDocumentContext.swift @@ -1,7 +1,7 @@ import Foundation import SuggestionBasic -public struct ActiveDocumentContext { +public struct ActiveDocumentContext: Sendable { public var documentURL: URL public var relativePath: String public var language: CodeLanguage @@ -13,8 +13,8 @@ public struct ActiveDocumentContext { public var imports: [String] public var includes: [String] - public struct FocusedContext { - public struct Context: Equatable { + public struct FocusedContext: Sendable { + public struct Context: Equatable, Sendable { public var signature: String public var name: String public var range: CursorRange diff --git a/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift b/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift index fd0bd460..23a9b729 100644 --- a/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift +++ b/Tool/Sources/OpenAIService/FucntionCall/ChatGPTFuntionProvider.swift @@ -6,7 +6,7 @@ public protocol ChatGPTFunctionProvider { var functionCallStrategy: FunctionCallStrategy? { get } } -extension ChatGPTFunctionProvider { +public extension ChatGPTFunctionProvider { func function(named: String) -> (any ChatGPTFunction)? { functions.first(where: { $0.name == named }) } diff --git a/Tool/Sources/SuggestionBasic/EditorInformation.swift b/Tool/Sources/SuggestionBasic/EditorInformation.swift index d9979890..ca243511 100644 --- a/Tool/Sources/SuggestionBasic/EditorInformation.swift +++ b/Tool/Sources/SuggestionBasic/EditorInformation.swift @@ -1,14 +1,14 @@ import Foundation import Parsing -public struct EditorInformation { - public struct LineAnnotation { +public struct EditorInformation: Sendable { + public struct LineAnnotation: Sendable { public var type: String public var line: Int public var message: String } - public struct SourceEditorContent { + public struct SourceEditorContent: Sendable { /// The content of the source editor. public var content: String /// The content of the source editor in lines. Every line should ends with `\n`. diff --git a/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift b/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift index f4345fd0..52983a6b 100644 --- a/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift +++ b/Tool/Sources/SuggestionBasic/ExportedFromLSP.swift @@ -1,4 +1,4 @@ -import LanguageServerProtocol +@preconcurrency import LanguageServerProtocol /// Line starts at 0. public typealias CursorPosition = LanguageServerProtocol.Position diff --git a/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift b/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift index a0478833..cc57246d 100644 --- a/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift +++ b/Tool/Sources/SuggestionBasic/LanguageIdentifierFromFilePath.swift @@ -1,7 +1,7 @@ import Foundation -import LanguageServerProtocol +@preconcurrency import LanguageServerProtocol -public enum CodeLanguage: RawRepresentable, Codable, CaseIterable, Hashable { +public enum CodeLanguage: RawRepresentable, Codable, CaseIterable, Hashable, Sendable { case builtIn(LanguageIdentifier) case plaintext case other(String) diff --git a/Tool/Sources/Workspace/Filespace.swift b/Tool/Sources/Workspace/Filespace.swift index d521bdbf..9f8f9184 100644 --- a/Tool/Sources/Workspace/Filespace.swift +++ b/Tool/Sources/Workspace/Filespace.swift @@ -62,7 +62,7 @@ public struct FilespaceCodeMetadata: Equatable { } @dynamicMemberLookup -public final class Filespace { +public final class Filespace: @unchecked Sendable { struct GitIgnoreStatus { var isIgnored: Bool var checkTime: Date diff --git a/Tool/Sources/Workspace/Workspace.swift b/Tool/Sources/Workspace/Workspace.swift index cb57f47d..e7dc9d0e 100644 --- a/Tool/Sources/Workspace/Workspace.swift +++ b/Tool/Sources/Workspace/Workspace.swift @@ -49,7 +49,7 @@ open class WorkspacePlugin { } @dynamicMemberLookup -public final class Workspace { +public final class Workspace: @unchecked Sendable { public struct UnsupportedFileError: Error, LocalizedError { public var extensionName: String public var errorDescription: String? { From adb332f35a1155fb6126c2e0989df6ddf94d9ff7 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 Oct 2025 18:26:47 +0800 Subject: [PATCH 06/19] Adjust WebScrapper --- Tool/Sources/WebScrapper/WebScrapper.swift | 36 ++++++++++++---------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/Tool/Sources/WebScrapper/WebScrapper.swift b/Tool/Sources/WebScrapper/WebScrapper.swift index 78f0a120..e7c45725 100644 --- a/Tool/Sources/WebScrapper/WebScrapper.swift +++ b/Tool/Sources/WebScrapper/WebScrapper.swift @@ -6,9 +6,11 @@ import WebKit public final class WebScrapper { final class NavigationDelegate: NSObject, WKNavigationDelegate { weak var scrapper: WebScrapper? - - public nonisolated func webView(_: WKWebView, didFinish _: WKNavigation!) { + + public nonisolated func webView(_ webView: WKWebView, didFinish _: WKNavigation!) { Task { @MainActor in + let scrollToBottomScript = "window.scrollTo(0, document.body.scrollHeight);" + _ = try? await webView.evaluateJavaScript(scrollToBottomScript) self.scrapper?.webViewDidFinishLoading = true } } @@ -29,7 +31,7 @@ public final class WebScrapper { var webViewDidFinishLoading = false var navigationError: (any Error)? - let navigationDelegate: NavigationDelegate = NavigationDelegate() + let navigationDelegate: NavigationDelegate = .init() enum WebScrapperError: Error { case retry @@ -38,15 +40,6 @@ public final class WebScrapper { public init() async { let jsonRuleList = ###""" [ - { - "trigger": { - "url-filter": ".*", - "resource-type": ["style-sheet"] - }, - "action": { - "type": "block" - } - }, { "trigger": { "url-filter": ".*", @@ -91,9 +84,8 @@ public final class WebScrapper { configuration.defaultWebpagePreferences.preferredContentMode = .desktop configuration.defaultWebpagePreferences.allowsContentJavaScript = true configuration.websiteDataStore = .nonPersistent() - configuration - .applicationNameForUserAgent = - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.5 Safari/605.1.15" + configuration.applicationNameForUserAgent = + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.0.1 Safari/605.1.15" if #available(iOS 17.0, macOS 14.0, *) { configuration.allowsInlinePredictions = false @@ -134,8 +126,18 @@ public final class WebScrapper { retryCount += 1 try await Task.sleep(nanoseconds: 100_000_000) } - - throw CancellationError() + + enum Error: Swift.Error, LocalizedError { + case failToValidate + + var errorDescription: String? { + switch self { + case .failToValidate: + return "Failed to validate the HTML content within the given timeout and retry limit." + } + } + } + throw Error.failToValidate } func getHTML() async throws -> String { From ae59139dbb1c5384ba196164bfdccac18ba56fc7 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 Oct 2025 21:35:57 +0800 Subject: [PATCH 07/19] Add settings view for Xcode chat proxy --- .../Sources/HostApp/FeatureSettingsView.swift | 30 +++++++++++-------- .../SharedUIComponents/TabContainer.swift | 14 ++++++++- 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift index 4df4f10c..03efb50c 100644 --- a/Core/Sources/HostApp/FeatureSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettingsView.swift @@ -1,6 +1,11 @@ import SwiftUI +import SharedUIComponents struct FeatureSettingsView: View { + var tabContainer: ExternalTabContainer { + ExternalTabContainer.tabContainer(for: "Features") + } + @State var tag = 0 var body: some View { @@ -40,18 +45,18 @@ struct FeatureSettingsView: View { subtitle: "Xcode related features", image: "app" ) - -// #if canImport(ProHostApp) -// ScrollView { -// TerminalSettingsView().padding() -// } -// .sidebarItem( -// tag: 3, -// title: "Terminal", -// subtitle: "Terminal chat tab", -// image: "terminal" -// ) -// #endif + + ForEach(Array(tabContainer.tabs.enumerated()), id: \.1.id) { index, tab in + ScrollView { + tab.viewBuilder().padding() + } + .sidebarItem( + tag: 4 + index, + title: tab.title, + subtitle: tab.description, + image: tab.image + ) + } } } } @@ -62,4 +67,3 @@ struct FeatureSettingsView_Previews: PreviewProvider { .frame(width: 800) } } - diff --git a/Tool/Sources/SharedUIComponents/TabContainer.swift b/Tool/Sources/SharedUIComponents/TabContainer.swift index 06611861..9a61d93b 100644 --- a/Tool/Sources/SharedUIComponents/TabContainer.swift +++ b/Tool/Sources/SharedUIComponents/TabContainer.swift @@ -8,17 +8,20 @@ public final class ExternalTabContainer { public struct TabItem: Identifiable { public var id: String public var title: String + public var description: String public var image: String public let viewBuilder: () -> AnyView public init( id: String, title: String, + description: String = "", image: String = "", @ViewBuilder viewBuilder: @escaping () -> V ) { self.id = id self.title = title + self.description = description self.image = image self.viewBuilder = { AnyView(viewBuilder()) } } @@ -46,22 +49,31 @@ public final class ExternalTabContainer { public func registerTab( id: String, title: String, + description: String = "", image: String = "", @ViewBuilder viewBuilder: @escaping () -> V ) { - tabs.append(TabItem(id: id, title: title, image: image, viewBuilder: viewBuilder)) + tabs.append(TabItem( + id: id, + title: title, + description: description, + image: image, + viewBuilder: viewBuilder + )) } public static func registerTab( for tabContainerId: String, id: String, title: String, + description: String = "", image: String = "", @ViewBuilder viewBuilder: @escaping () -> V ) { tabContainer(for: tabContainerId).registerTab( id: id, title: title, + description: description, image: image, viewBuilder: viewBuilder ) From 5aa488e465f8b1fd1185801ffdcb477b3226ef24 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Tue, 21 Oct 2025 22:10:11 +0800 Subject: [PATCH 08/19] Specify the document URL when resolving the context --- .../FeatureReducers/PromptToCodePanel.swift | 7 ++++--- .../PromptToCodeCustomization.swift | 10 ++++++++-- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift index 44b53f96..48fac7f4 100644 --- a/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift +++ b/Core/Sources/SuggestionWidget/FeatureReducers/PromptToCodePanel.swift @@ -139,9 +139,10 @@ public struct PromptToCodePanel { return .run { send in do { - let context = await contextInputController.resolveContext(onStatusChange: { - await send(.statusUpdated($0)) - }) + let context = await contextInputController.resolveContext( + forDocumentURL: copiedState.promptToCodeState.source.documentURL, + onStatusChange: { await send(.statusUpdated($0)) } + ) await send(.referencesUpdated(context.references)) let agentFactory = context.agent ?? { SimpleModificationAgent() } _ = try await withThrowingTaskGroup(of: Void.self) { group in diff --git a/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift index 46eccad2..a952311b 100644 --- a/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift +++ b/Tool/Sources/PromptToCodeCustomization/PromptToCodeCustomization.swift @@ -55,7 +55,10 @@ public protocol PromptToCodeContextInputControllerDelegate { public protocol PromptToCodeContextInputController: Perception.Perceptible { var instruction: NSAttributedString { get set } - func resolveContext(onStatusChange: @escaping ([String]) async -> Void) async -> ( + func resolveContext( + forDocumentURL: URL, + onStatusChange: @escaping ([String]) async -> Void + ) async -> ( instruction: String, references: [ChatMessage.Reference], topics: [ChatMessage.Reference], @@ -100,7 +103,10 @@ public final class DefaultPromptToCodeContextInputController: PromptToCodeContex instruction = mutable } - public func resolveContext(onStatusChange: @escaping ([String]) async -> Void) -> ( + public func resolveContext( + forDocumentURL: URL, + onStatusChange: @escaping ([String]) async -> Void + ) -> ( instruction: String, references: [ChatMessage.Reference], topics: [ChatMessage.Reference], From 888a4363b584bd7e22b98cd627972919b8897055 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 Oct 2025 02:56:21 +0800 Subject: [PATCH 09/19] Add images to ChatMessage --- Tool/Sources/ChatBasic/ChatMessage.swift | 25 +++++++++++++++++++ .../APIs/ChatCompletionsAPIDefinition.swift | 5 ++-- .../APIs/ClaudeChatCompletionsService.swift | 2 +- .../OpenAIService/ChatGPTService.swift | 14 ++++++++++- 4 files changed, 41 insertions(+), 5 deletions(-) diff --git a/Tool/Sources/ChatBasic/ChatMessage.swift b/Tool/Sources/ChatBasic/ChatMessage.swift index 74aace10..ab5f04a4 100644 --- a/Tool/Sources/ChatBasic/ChatMessage.swift +++ b/Tool/Sources/ChatBasic/ChatMessage.swift @@ -115,6 +115,25 @@ public struct ChatMessage: Equatable, Codable, Sendable { } } + public struct Image: Equatable, Sendable, Codable { + public enum Format: String, Sendable, Codable { + case png = "image/png" + case jpeg = "image/jpeg" + case gif = "image/gif" + } + + public var base64EncodedData: String + public var format: Format + public var urlString: String { + "data:\(format.rawValue);base64,\(base64EncodedData)" + } + + public init(base64EncodedData: String, format: Format) { + self.base64EncodedData = base64EncodedData + self.format = format + } + } + /// The role of a message. @FallbackDecoding public var role: Role @@ -153,6 +172,10 @@ public struct ChatMessage: Equatable, Codable, Sendable { @FallbackDecoding> public var references: [Reference] + /// The images associated with this message. + @FallbackDecoding> + public var images: [Image] + /// Cache the message in the prompt if possible. public var cacheIfPossible: Bool @@ -175,6 +198,7 @@ public struct ChatMessage: Equatable, Codable, Sendable { summary: String? = nil, tokenCount: Int? = nil, references: [Reference] = [], + images: [Image] = [], cacheIfPossible: Bool = false ) { self.role = role @@ -187,6 +211,7 @@ public struct ChatMessage: Equatable, Codable, Sendable { self.id = id tokensCount = tokenCount self.references = references + self.images = images self.cacheIfPossible = cacheIfPossible } } diff --git a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift index 87a5e73e..76325f6f 100644 --- a/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift +++ b/Tool/Sources/OpenAIService/APIs/ChatCompletionsAPIDefinition.swift @@ -32,12 +32,11 @@ struct ChatCompletionsRequestBody: Equatable { case jpeg = "image/jpeg" case gif = "image/gif" } - var data: Data + var base64EncodeData: String var format: Format var dataURLString: String { - let base64 = data.base64EncodedString() - return "data:\(format.rawValue);base64,\(base64)" + return "data:\(format.rawValue);base64,\(base64EncodeData)" } } diff --git a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift index 548ca8a1..0b41c053 100644 --- a/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/ClaudeChatCompletionsService.swift @@ -466,7 +466,7 @@ extension ClaudeChatCompletionsService.RequestBody { content.append(.init(type: .image, source: .init( type: "base64", media_type: image.format.rawValue, - data: image.data.base64EncodedString() + data: image.base64EncodeData ))) } diff --git a/Tool/Sources/OpenAIService/ChatGPTService.swift b/Tool/Sources/OpenAIService/ChatGPTService.swift index 45d6490d..b02f4f2b 100644 --- a/Tool/Sources/OpenAIService/ChatGPTService.swift +++ b/Tool/Sources/OpenAIService/ChatGPTService.swift @@ -578,6 +578,18 @@ extension ChatGPTService { } let messages = prompt.history.flatMap { chatMessage in + let images = chatMessage.images.map { image in + ChatCompletionsRequestBody.Message.Image( + base64EncodeData: image.base64EncodedData, + format: { + switch image.format { + case .png: .png + case .jpeg: .jpeg + case .gif: .gif + } + }() + ) + } var all = [ChatCompletionsRequestBody.Message]() all.append(ChatCompletionsRequestBody.Message( role: { @@ -605,7 +617,7 @@ extension ChatGPTService { nil } }(), - images: [], + images: images, audios: [], cacheIfPossible: chatMessage.cacheIfPossible )) From d8afbec7914578633b4123215cf50c59248fb1ff Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 Oct 2025 15:57:35 +0800 Subject: [PATCH 10/19] Add images support to chat model --- .../ChatModelManagement/ChatModelEdit.swift | 8 +++-- .../ChatModelEditView.swift | 28 +++++++++++++++++ .../APIs/OpenAIChatCompletionsService.swift | 5 ++++ .../Memory/TemplateChatGPTMemory.swift | 30 +++++++++---------- 4 files changed, 54 insertions(+), 17 deletions(-) diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift index 7b4869fc..f0c673e5 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEdit.swift @@ -35,6 +35,7 @@ struct ChatModelEdit { var openAICompatibleSupportsMultipartMessageContent = true var requiresBeginWithUserMessage = false var customBody: String = "" + var supportsImages: Bool = true } enum Action: Equatable, BindableAction { @@ -290,7 +291,9 @@ extension ChatModel { return state.supportsFunctionCalling } }(), - modelName: state.modelName.trimmingCharacters(in: .whitespacesAndNewlines), + supportsImage: state.supportsImages, + modelName: state.modelName + .trimmingCharacters(in: .whitespacesAndNewlines), openAIInfo: .init( organizationID: state.openAIOrganizationID, projectID: state.openAIProjectID @@ -331,7 +334,8 @@ extension ChatModel { openAICompatibleSupportsMultipartMessageContent: info.openAICompatibleInfo .supportsMultipartMessageContent, requiresBeginWithUserMessage: info.openAICompatibleInfo.requiresBeginWithUserMessage, - customBody: info.customBodyInfo.jsonBody + customBody: info.customBodyInfo.jsonBody, + supportsImages: info.supportsImage ) } } diff --git a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift index fd4256cd..d16b7556 100644 --- a/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift +++ b/Core/Sources/HostApp/AccountSettings/ChatModelManagement/ChatModelEditView.swift @@ -358,6 +358,10 @@ struct ChatModelEditView: View { TextField(text: $store.openAIProjectID, prompt: Text("Optional")) { Text("Project ID") } + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } VStack(alignment: .leading, spacing: 8) { Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( @@ -386,6 +390,10 @@ struct ChatModelEditView: View { MaxTokensTextField(store: store) SupportsFunctionCallingToggle(store: store) + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } } } } @@ -435,6 +443,10 @@ struct ChatModelEditView: View { Toggle(isOn: $store.requiresBeginWithUserMessage) { Text("Requires the first message to be from the user") } + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } } } } @@ -473,6 +485,10 @@ struct ChatModelEditView: View { MaxTokensTextField(store: store) TextField("API Version", text: $store.apiVersion, prompt: Text("v1")) + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } } } } @@ -496,6 +512,10 @@ struct ChatModelEditView: View { Text("Keep Alive") } + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } + VStack(alignment: .leading, spacing: 8) { Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( " For more details, please visit [https://ollama.com](https://ollama.com)." @@ -539,6 +559,10 @@ struct ChatModelEditView: View { } MaxTokensTextField(store: store) + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } VStack(alignment: .leading, spacing: 8) { Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( @@ -572,6 +596,10 @@ struct ChatModelEditView: View { Toggle(isOn: $store.openAICompatibleSupportsMultipartMessageContent) { Text("Support multi-part message content") } + + Toggle(isOn: $store.supportsImages) { + Text("Supports Images") + } VStack(alignment: .leading, spacing: 8) { Text(Image(systemName: "exclamationmark.triangle.fill")) + Text( diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index c1ad2027..bf1e22b0 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -1028,6 +1028,11 @@ extension OpenAIChatCompletionsService.RequestBody { supportsTemperature: Bool, supportsSystemPrompt: Bool ) { + let supportsMultipartMessageContent = if supportsAudio || supportsImage { + true + } else { + supportsMultipartMessageContent + } temperature = body.temperature stream = body.stream stop = body.stop diff --git a/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift index da2205f7..2325e0c4 100644 --- a/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift +++ b/Tool/Sources/OpenAIService/Memory/TemplateChatGPTMemory.swift @@ -125,7 +125,7 @@ public struct MemoryTemplate { return formatter(list.map { $0.0 }) } } - + let composedContent = contents.joined(separator: "\n\n") if composedContent.isEmpty { return nil } @@ -162,7 +162,7 @@ public struct MemoryTemplate { _ messages: inout [Message], _ followUpMessages: inout [ChatMessage] ) async throws -> Void - + let truncateRule: TruncateRule? public init( @@ -187,7 +187,7 @@ public struct MemoryTemplate { mutating func truncate() async throws { if Task.isCancelled { return } - + if let truncateRule = truncateRule { try await truncateRule(&messages, &followUpMessages) return @@ -195,7 +195,7 @@ public struct MemoryTemplate { try await Self.defaultTruncateRule()(&messages, &followUpMessages) } - + public struct DefaultTruncateRuleOptions { public var numberOfContentListItemToKeep: (Int) -> Int = { $0 * 2 / 3 } } @@ -206,14 +206,14 @@ public struct MemoryTemplate { var options = DefaultTruncateRuleOptions() updateOptions(&options) return { messages, followUpMessages in - + // 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) @@ -222,9 +222,9 @@ public struct MemoryTemplate { } return } - + // Remove according to the priority. - + var truncatingMessageIndex: Int? for (index, message) in messages.enumerated() { if message.priority == .max { continue } @@ -234,20 +234,20 @@ public struct MemoryTemplate { 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 } @@ -257,13 +257,13 @@ public struct MemoryTemplate { 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) From d782b068ac254c1f60c960a6207942a5e97b15a0 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 Oct 2025 15:57:45 +0800 Subject: [PATCH 11/19] Add todo --- .../AutoManagedChatGPTMemoryOpenAIStrategy.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift index 07a6bda5..d5842b83 100644 --- a/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift +++ b/Tool/Sources/OpenAIService/Memory/AutoManagedChatGPTMemoryStrategy/AutoManagedChatGPTMemoryOpenAIStrategy.swift @@ -4,6 +4,7 @@ import Logger import TokenEncoder extension AutoManagedChatGPTMemory { + #warning("TODO: Need to fix the tokenizer or supports model specified tokenizers.") struct OpenAIStrategy: AutoManagedChatGPTMemoryStrategy { static let encoder: TokenEncoder = TiktokenCl100kBaseTokenEncoder() From 5ed3c9d2795646da043675d32d1788a79d791686 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 Oct 2025 16:04:37 +0800 Subject: [PATCH 12/19] Remove warnings --- Tool/Package.swift | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/Tool/Package.swift b/Tool/Package.swift index 69e17995..0dcdfc6f 100644 --- a/Tool/Package.swift +++ b/Tool/Package.swift @@ -223,6 +223,7 @@ let package = Package( name: "ModificationBasic", dependencies: [ "SuggestionBasic", + "ChatBasic", .product(name: "CodableWrappers", package: "CodableWrappers"), .product( name: "ComposableArchitecture", @@ -236,6 +237,7 @@ let package = Package( dependencies: [ "ModificationBasic", "SuggestionBasic", + "ChatBasic", .product( name: "ComposableArchitecture", package: "swift-composable-architecture" @@ -495,10 +497,16 @@ let package = Package( .target( name: "ChatTab", - dependencies: [.product( - name: "ComposableArchitecture", - package: "swift-composable-architecture" - )] + dependencies: [ + "Preferences", + "Configs", + "AIModel", + .product(name: "CodableWrappers", package: "CodableWrappers"), + .product( + name: "ComposableArchitecture", + package: "swift-composable-architecture" + ), + ] ), // MARK: - Chat Context Collector From efcd96c99de509fd2182a142af315f89ababc7aa Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 Oct 2025 16:04:50 +0800 Subject: [PATCH 13/19] Bump version --- Version.xcconfig | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Version.xcconfig b/Version.xcconfig index 3235a1bd..ddbce34a 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,4 +1,4 @@ -APP_VERSION = 0.35.11 -APP_BUILD = 470 +APP_VERSION = 0.36.0 +APP_BUILD = 474 RELEASE_CHANNEL = RELEASE_NUMBER = 1 From 1c9d1d7994daf26c5f09a16996281650edad382c Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 Oct 2025 17:29:46 +0800 Subject: [PATCH 14/19] Fix activeXcode not set at first launch --- Tool/Sources/XcodeInspector/XcodeInspector.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tool/Sources/XcodeInspector/XcodeInspector.swift b/Tool/Sources/XcodeInspector/XcodeInspector.swift index 3f97c637..d06c69c8 100644 --- a/Tool/Sources/XcodeInspector/XcodeInspector.swift +++ b/Tool/Sources/XcodeInspector/XcodeInspector.swift @@ -249,6 +249,7 @@ public final class XcodeInspector: Sendable { activeApplication = activeXcode ?? runningApplications .first(where: \.isActive) .map(AppInstanceInspector.init(runningApplication:)) + self.activeXcode = activeXcode } appChangeObservations.forEach { $0.cancel() } From 5b6ecc9bb552711caac41aea414a46cba03985b4 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 Oct 2025 17:30:05 +0800 Subject: [PATCH 15/19] Fix source editor detection --- Tool/Sources/AXExtension/AXUIElement.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Tool/Sources/AXExtension/AXUIElement.swift b/Tool/Sources/AXExtension/AXUIElement.swift index 772a75af..71c48d06 100644 --- a/Tool/Sources/AXExtension/AXUIElement.swift +++ b/Tool/Sources/AXExtension/AXUIElement.swift @@ -58,7 +58,9 @@ public extension AXUIElement { } var isSourceEditor: Bool { - description == "Source Editor" && roleDescription != "unknown" + if !(description == "Source Editor" && role != kAXUnknownRole) { return false } + if let _ = firstParent(where: { $0.identifier == "editor context" }) { return true } + return false } var selectedTextRange: ClosedRange? { From 9cb67eff8d2a8741b2274cc94a99896c3f09f446 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Wed, 22 Oct 2025 23:28:50 +0800 Subject: [PATCH 16/19] Convert the circular widget to another form that is less obvious --- ...stionFeatureDisabledLanguageListView.swift | 2 +- ...gestionFeatureEnabledProjectListView.swift | 2 +- Core/Sources/HostApp/GeneralView.swift | 2 +- Core/Sources/SuggestionWidget/Styles.swift | 5 +- .../WidgetPositionStrategy.swift | 32 +-- .../Sources/SuggestionWidget/WidgetView.swift | 204 +++++++++++------- .../WidgetWindowsController.swift | 24 +-- README.md | 8 +- 8 files changed, 166 insertions(+), 113 deletions(-) diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift index 41fa9fb4..6d894cfd 100644 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureDisabledLanguageListView.swift @@ -74,7 +74,7 @@ struct SuggestionFeatureDisabledLanguageListView: View { if settings.suggestionFeatureDisabledLanguageList.isEmpty { Text(""" Empty - Disable the language of a file by right clicking the circular widget. + Disable the language of a file by right clicking the indicator widget. """) .multilineTextAlignment(.center) .padding() diff --git a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift index f57cd5e4..0cf66ca6 100644 --- a/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift +++ b/Core/Sources/HostApp/FeatureSettings/Suggestion/SuggestionFeatureEnabledProjectListView.swift @@ -82,7 +82,7 @@ struct SuggestionFeatureEnabledProjectListView: View { Text(""" Empty Add project with "+" button - Or right clicking the circular widget + Or right clicking the indicator widget """) .multilineTextAlignment(.center) } diff --git a/Core/Sources/HostApp/GeneralView.swift b/Core/Sources/HostApp/GeneralView.swift index 6b08c69b..b69c0127 100644 --- a/Core/Sources/HostApp/GeneralView.swift +++ b/Core/Sources/HostApp/GeneralView.swift @@ -280,7 +280,7 @@ struct GeneralSettingsView: View { } Toggle(isOn: $settings.hideCircularWidget) { - Text("Hide circular widget") + Text("Hide indicator widget") } }.padding() } diff --git a/Core/Sources/SuggestionWidget/Styles.swift b/Core/Sources/SuggestionWidget/Styles.swift index 825c571b..c2f1436b 100644 --- a/Core/Sources/SuggestionWidget/Styles.swift +++ b/Core/Sources/SuggestionWidget/Styles.swift @@ -8,9 +8,10 @@ enum Style { static let panelWidth: Double = 454 static let inlineSuggestionMinWidth: Double = 540 static let inlineSuggestionMaxHeight: Double = 400 - static let widgetHeight: Double = 20 - static var widgetWidth: Double { widgetHeight } + static let widgetHeight: Double = 30 + static var widgetWidth: Double = 8 static let widgetPadding: Double = 4 + static let indicatorBottomPadding: Double = 40 static let chatWindowTitleBarHeight: Double = 24 static let trafficLightButtonSize: Double = 12 } diff --git a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift index b7ceb487..5aed84b3 100644 --- a/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift +++ b/Core/Sources/SuggestionWidget/WidgetPositionStrategy.swift @@ -17,6 +17,7 @@ public struct WidgetLocation: Equatable { enum UpdateLocationStrategy { struct AlignToTextCursor { func framesForWindows( + windowFrame: CGRect, editorFrame: CGRect, mainScreen: NSScreen, activeScreen: NSScreen, @@ -33,6 +34,7 @@ enum UpdateLocationStrategy { ) else { return FixedToBottom().framesForWindows( + windowFrame: windowFrame, editorFrame: editorFrame, mainScreen: mainScreen, activeScreen: activeScreen, @@ -43,6 +45,7 @@ enum UpdateLocationStrategy { let found = AXValueGetValue(rect, .cgRect, &frame) guard found else { return FixedToBottom().framesForWindows( + windowFrame: windowFrame, editorFrame: editorFrame, mainScreen: mainScreen, activeScreen: activeScreen, @@ -51,6 +54,7 @@ enum UpdateLocationStrategy { } return HorizontalMovable().framesForWindows( y: mainScreen.frame.height - frame.maxY, + windowFrame: windowFrame, alignPanelTopToAnchor: nil, editorFrame: editorFrame, mainScreen: mainScreen, @@ -63,6 +67,7 @@ enum UpdateLocationStrategy { struct FixedToBottom { func framesForWindows( + windowFrame: CGRect, editorFrame: CGRect, mainScreen: NSScreen, activeScreen: NSScreen, @@ -73,6 +78,7 @@ enum UpdateLocationStrategy { ) -> WidgetLocation { var frames = HorizontalMovable().framesForWindows( y: mainScreen.frame.height - editorFrame.maxY + Style.widgetPadding, + windowFrame: windowFrame, alignPanelTopToAnchor: false, editorFrame: editorFrame, mainScreen: mainScreen, @@ -97,6 +103,7 @@ enum UpdateLocationStrategy { struct HorizontalMovable { func framesForWindows( y: CGFloat, + windowFrame: CGRect, alignPanelTopToAnchor fixedAlignment: Bool?, editorFrame: CGRect, mainScreen: NSScreen, @@ -130,6 +137,13 @@ enum UpdateLocationStrategy { width: Style.widgetWidth, height: Style.widgetHeight ) + + let widgetFrame = CGRect( + x: windowFrame.minX, + y: mainScreen.frame.height - windowFrame.maxY + Style.indicatorBottomPadding, + width: Style.widgetWidth, + height: Style.widgetHeight + ) if !hideCircularWidget { proposedAnchorFrameOnTheRightSide = widgetFrameOnTheRightSide @@ -150,7 +164,7 @@ enum UpdateLocationStrategy { x: proposedPanelX, y: alignPanelTopToAnchor ? anchorFrame.maxY - Style.panelHeight - : anchorFrame.minY - editorFrameExpendedSize.height, + : anchorFrame.minY, width: Style.panelWidth, height: Style.panelHeight ) @@ -164,7 +178,7 @@ enum UpdateLocationStrategy { ) return .init( - widgetFrame: widgetFrameOnTheRightSide, + widgetFrame: widgetFrame, tabFrame: tabFrame, sharedPanelLocation: .init( frame: panelFrame, @@ -227,7 +241,7 @@ enum UpdateLocationStrategy { height: Style.widgetHeight ) return .init( - widgetFrame: widgetFrameOnTheLeftSide, + widgetFrame: widgetFrame, tabFrame: tabFrame, sharedPanelLocation: .init( frame: panelFrame, @@ -244,10 +258,8 @@ enum UpdateLocationStrategy { let panelFrame = CGRect( x: anchorFrame.maxX - Style.panelWidth, y: alignPanelTopToAnchor - ? anchorFrame.maxY - Style.panelHeight - Style.widgetHeight - - Style.widgetPadding - : anchorFrame.maxY + Style.widgetPadding - - editorFrameExpendedSize.height, + ? anchorFrame.maxY - Style.panelHeight + : anchorFrame.maxY - editorFrameExpendedSize.height, width: Style.panelWidth, height: Style.panelHeight ) @@ -258,7 +270,7 @@ enum UpdateLocationStrategy { height: Style.widgetHeight ) return .init( - widgetFrame: widgetFrameOnTheRightSide, + widgetFrame: widgetFrame, tabFrame: tabFrame, sharedPanelLocation: .init( frame: panelFrame, @@ -426,10 +438,6 @@ enum UpdateLocationStrategy { let foundFirstLine = AXValueGetValue(selectedRange, .cfRange, &firstLineRange) firstLineRange.length = 0 - #warning( - "FIXME: When selection is too low and out of the screen, the selection range becomes something else." - ) - if foundFirstLine, let firstLineSelectionRange = AXValueCreate(.cfRange, &firstLineRange), let firstLineRect: AXValue = try? editor.copyParameterizedValue( diff --git a/Core/Sources/SuggestionWidget/WidgetView.swift b/Core/Sources/SuggestionWidget/WidgetView.swift index fdf50c87..7ae716f6 100644 --- a/Core/Sources/SuggestionWidget/WidgetView.swift +++ b/Core/Sources/SuggestionWidget/WidgetView.swift @@ -1,6 +1,7 @@ import ActiveApplicationMonitor import ComposableArchitecture import Preferences +import SharedUIComponents import SuggestionBasic import SwiftUI @@ -13,18 +14,23 @@ struct WidgetView: View { @AppStorage(\.hideCircularWidget) var hideCircularWidget var body: some View { - WithPerceptionTracking { - Circle() - .fill(isHovering ? .white.opacity(0.5) : .white.opacity(0.15)) + GeometryReader { _ in + WithPerceptionTracking { + ZStack { + WidgetAnimatedCapsule( + store: store, + isHovering: isHovering + ) + } .onTapGesture { store.send(.widgetClicked, animation: .easeInOut(duration: 0.2)) } - .overlay { WidgetAnimatedCircle(store: store) } .onHover { yes in - withAnimation(.easeInOut(duration: 0.2)) { + withAnimation(.easeInOut(duration: 0.14)) { isHovering = yes } - }.contextMenu { + } + .contextMenu { WidgetContextMenu(store: store) } .opacity({ @@ -32,100 +38,140 @@ struct WidgetView: View { return store.isProcessing ? 1 : 0 }()) .animation( - featureFlag: \.animationCCrashSuggestion, + .easeInOut(duration: 0.2), + value: isHovering + ) + .animation( .easeInOut(duration: 0.2), value: store.isProcessing ) + } } } } -struct WidgetAnimatedCircle: View { +struct WidgetAnimatedCapsule: View { let store: StoreOf - @State var processingProgress: Double = 0 + var isHovering: Bool - struct OverlayCircleState: Equatable { - var isProcessing: Bool - var isContentEmpty: Bool - } + @State private var animatedProgress: CGFloat = 0 // 0~1 + @State private var animationTask: Task? + + private let movingSegmentLength: CGFloat = 0.28 var body: some View { - WithPerceptionTracking { - let minimumLineWidth: Double = 3 - let lineWidth = (1 - processingProgress) * - (Style.widgetWidth - minimumLineWidth / 2) + minimumLineWidth - let scale = max(processingProgress * 1, 0.0001) - ZStack { - Circle() - .stroke( - Color(nsColor: .darkGray), - style: .init(lineWidth: minimumLineWidth) - ) - .padding(minimumLineWidth / 2) - - // how do I stop the repeatForever animation without removing the view? - // I tried many solutions found on stackoverflow but non of them works. - Group { - if store.isProcessing { - Circle() - .stroke( - Color.accentColor, - style: .init(lineWidth: lineWidth) - ) - .padding(minimumLineWidth / 2) - .scaleEffect(x: scale, y: scale) - .opacity( - !store.isContentEmpty || store.isProcessing ? 1 : 0 - ) - .animation( - featureFlag: \.animationCCrashSuggestion, - .easeInOut(duration: 1) - .repeatForever(autoreverses: true), - value: processingProgress - ) - } else { - Circle() - .stroke( - Color.accentColor, - style: .init(lineWidth: lineWidth) - ) - .padding(minimumLineWidth / 2) - .scaleEffect(x: scale, y: scale) - .opacity( - !store.isContentEmpty || store.isProcessing ? 1 : 0 - ) - .animation( - featureFlag: \.animationCCrashSuggestion, - .easeInOut(duration: 1), - value: processingProgress - ) - } + GeometryReader { geo in + WithPerceptionTracking { + let capsuleWidth = geo.size.width + let capsuleHeight = geo.size.height + + let backgroundWidth = capsuleWidth + let foregroundWidth = max(capsuleWidth - 4, 2) + let padding = (backgroundWidth - foregroundWidth) / 2 + + ZStack { + Capsule() + .modify { + if #available(macOS 26.0, *) { + $0.glassEffect() + } else if #available(macOS 13.0, *) { + $0.backgroundStyle(.thickMaterial.opacity(0.8)).overlay( + Capsule().stroke( + Color(nsColor: .darkGray).opacity(0.2), + lineWidth: 1 + ) + ) + } else { + $0.fill(Color(nsColor: .darkGray).opacity(0.6)).overlay( + Capsule().stroke( + Color(nsColor: .darkGray).opacity(0.2), + lineWidth: 1 + ) + ) + } + } + .frame(width: backgroundWidth, height: capsuleHeight) + .animation(.easeInOut(duration: 0.14), value: isHovering) + + Capsule() + .fill(Color.accentColor.opacity(0.8)) + .frame( + width: foregroundWidth, + height: capsuleHeight * movingSegmentLength + ) + .opacity(store.isProcessing ? 1 : 0) + .position( + x: capsuleWidth / 2, + y: { + let height = capsuleHeight - padding * 2 + let base = padding + return base + height * (normalizedStart() + movingSegmentLength / 2) + }() + ) + .animation(nil, value: store.isProcessing) + .animation(.easeInOut(duration: 0.14), value: isHovering) } - .onChange(of: store.isProcessing) { _ in - refreshRing( - isProcessing: store.isProcessing, - isContentEmpty: store.isContentEmpty - ) + .onAppear { + updateAnimationTask(isProcessing: store.isProcessing) + } + .onChange(of: store.isProcessing) { newValue in + updateAnimationTask(isProcessing: newValue) } .onChange(of: store.isContentEmpty) { _ in - refreshRing( - isProcessing: store.isProcessing, - isContentEmpty: store.isContentEmpty - ) + if !store.isProcessing { + animatedProgress = store.isContentEmpty ? 0 : 1 + } } + .onChange(of: isHovering) { _ in } } } } - func refreshRing(isProcessing: Bool, isContentEmpty: Bool) { + // 进度条起点 + private func normalizedStart() -> CGFloat { + let p = max(0, min(1, animatedProgress)) + return p * (1 - movingSegmentLength) + } + + // 动画任务 + private func updateAnimationTask(isProcessing: Bool) { + animationTask?.cancel() + animationTask = nil + if isProcessing { - processingProgress = 1 - processingProgress + animationTask = Task { [weak store] in + await MainActor.run { + animatedProgress = 0 + } + while !Task.isCancelled { + await MainActor.run { + withAnimation(.linear(duration: 1.2)) { + animatedProgress = 1 + } + } + try? await Task.sleep(nanoseconds: UInt64(1.2 * 1_000_000_000)) + if Task.isCancelled { break } + if !(store?.isProcessing ?? true) { break } + await MainActor.run { + withAnimation(.linear(duration: 1.2)) { + animatedProgress = 0 + } + } + try? await Task.sleep(nanoseconds: UInt64(1.2 * 1_000_000_000)) + if Task.isCancelled { break } + if !(store?.isProcessing ?? true) { break } + } + } } else { - processingProgress = isContentEmpty ? 0 : 1 + withAnimation(.easeInOut(duration: 0.2)) { + animatedProgress = store.isContentEmpty ? 0 : 1 + } } } } +// 下面的WidgetContextMenu和其它内容保持不变喵~ + struct WidgetContextMenu: View { @AppStorage(\.useGlobalChat) var useGlobalChat @AppStorage(\.realtimeSuggestionToggle) var realtimeSuggestionToggle @@ -145,7 +191,7 @@ struct WidgetContextMenu: View { }) { Text("Open Chat") } - + Button(action: { store.send(.openModificationButtonClicked) }) { @@ -267,6 +313,7 @@ struct WidgetView_Preview: PreviewProvider { ), isHovering: false ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) WidgetView( store: Store( @@ -281,6 +328,7 @@ struct WidgetView_Preview: PreviewProvider { ), isHovering: true ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) WidgetView( store: Store( @@ -295,6 +343,7 @@ struct WidgetView_Preview: PreviewProvider { ), isHovering: false ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) WidgetView( store: Store( @@ -309,8 +358,9 @@ struct WidgetView_Preview: PreviewProvider { ), isHovering: false ) + .frame(width: Style.widgetWidth, height: Style.widgetHeight) } - .frame(width: 30) + .frame(width: 200, height: 200) .background(Color.black) } } diff --git a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift index b9999144..d53d7867 100644 --- a/Core/Sources/SuggestionWidget/WidgetWindowsController.swift +++ b/Core/Sources/SuggestionWidget/WidgetWindowsController.swift @@ -276,7 +276,9 @@ extension WidgetWindowsController { func generateWidgetLocation() async -> WidgetLocation? { if let application = await xcodeInspector.latestActiveXcode?.appElement { - if let focusElement = await xcodeInspector.focusedEditor?.element, + if let window = application.focusedWindow, + let windowFrame = window.rect, + let focusElement = await xcodeInspector.focusedEditor?.element, let parent = focusElement.parent, let frame = parent.rect, let screen = NSScreen.screens.first(where: { $0.frame.origin == .zero }), @@ -291,6 +293,7 @@ extension WidgetWindowsController { switch positionMode { case .fixedToBottom: var result = UpdateLocationStrategy.FixedToBottom().framesForWindows( + windowFrame: windowFrame, editorFrame: frame, mainScreen: screen, activeScreen: windowContainingScreen @@ -300,7 +303,8 @@ extension WidgetWindowsController { result.suggestionPanelLocation = UpdateLocationStrategy .NearbyTextCursor() .framesForSuggestionWindow( - editorFrame: frame, mainScreen: screen, + editorFrame: frame, + mainScreen: screen, activeScreen: windowContainingScreen, editor: focusElement, completionPanel: await xcodeInspector.completionPanel @@ -311,6 +315,7 @@ extension WidgetWindowsController { return result case .alignToTextCursor: var result = UpdateLocationStrategy.AlignToTextCursor().framesForWindows( + windowFrame: windowFrame, editorFrame: frame, mainScreen: screen, activeScreen: windowContainingScreen, @@ -358,24 +363,13 @@ extension WidgetWindowsController { frame = rect } - var expendedSize = CGSize.zero - if ["Xcode.WorkspaceWindow"].contains(window.identifier) { - // extra padding to bottom so buttons won't be covered - frame.size.height -= 40 - } else { - // move a bit away from the window so buttons won't be covered - frame.origin.x -= Style.widgetPadding + Style.widgetWidth / 2 - frame.size.width += Style.widgetPadding * 2 + Style.widgetWidth - expendedSize.width = (Style.widgetPadding * 2 + Style.widgetWidth) / 2 - expendedSize.height += Style.widgetPadding - } - return UpdateLocationStrategy.FixedToBottom().framesForWindows( + windowFrame: frame, editorFrame: frame, mainScreen: screen, activeScreen: firstScreen, preferredInsideEditorMinWidth: 9_999_999_999, // never - editorFrameExpendedSize: expendedSize + editorFrameExpendedSize: .zero ) } } diff --git a/README.md b/README.md index e74d5252..8ded90d5 100644 --- a/README.md +++ b/README.md @@ -215,7 +215,7 @@ The app can provide real-time code suggestions based on the files you have opene The feature provides two presentation modes: - Nearby Text Cursor: This mode shows suggestions based on the position of the text cursor. -- Floating Widget: This mode shows suggestions next to the circular widget. +- Floating Widget: This mode shows suggestions next to the indicator widget. When using the "Nearby Text Cursor" mode, it is recommended to set the real-time suggestion debounce to 0.1. @@ -251,7 +251,7 @@ The chat knows the following information: There are currently two tabs in the chat panel: one is available shared across Xcode, and the other is only available in the current file. -You can detach the chat panel by simply dragging it away. Once detached, the chat panel will remain visible even if Xcode is inactive. To re-attach it to the widget, click the message bubble button located next to the circular widget. +You can detach the chat panel by simply dragging it away. Once detached, the chat panel will remain visible even if Xcode is inactive. To re-attach it to the widget, click the message bubble button located next to the indicator widget. #### Commands @@ -262,7 +262,7 @@ You can detach the chat panel by simply dragging it away. Once detached, the cha | Shortcut | Description | | :------: | --------------------------------------------------------------------------------------------------- | | `⌘W` | Close the chat tab. | -| `⌘M` | Minimize the chat, you can bring it back with any chat commands or by clicking the circular widget. | +| `⌘M` | Minimize the chat, you can bring it back with any chat commands or by clicking the indicator widget. | | `⇧↩︎` | Add new line. | | `⇧⌘]` | Move to next tab | | `⇧⌘[` | Move to previous tab | @@ -309,7 +309,7 @@ This feature is recommended when you need to update a specific piece of code. So ### 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: +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 indicator widget. There are 3 types of custom commands: - 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. From 6bfb0d157a7296664535987ba5a5e52fa3646a7d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 Oct 2025 01:37:39 +0800 Subject: [PATCH 17/19] Always prefer text only content if possible in OpenAI Api --- .../APIs/OpenAIChatCompletionsService.swift | 38 ++++++++++++++++--- 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift index bf1e22b0..97e12b36 100644 --- a/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift +++ b/Tool/Sources/OpenAIService/APIs/OpenAIChatCompletionsService.swift @@ -271,6 +271,14 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet public struct RequestBody: Codable, Equatable { public typealias ClaudeCacheControl = ClaudeChatCompletionsService.RequestBody.CacheControl + + public struct GitHubCopilotCacheControl: Codable, Equatable, Sendable { + public var type: String + + public init(type: String = "ephemeral") { + self.type = type + } + } public struct Message: Codable, Equatable { public enum MessageContent: Codable, Equatable { @@ -457,6 +465,9 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet /// /// Deprecated. public var function_call: MessageFunctionCall? + #warning("TODO: when to use it?") + /// Cache control for GitHub Copilot models. + public var copilot_cache_control: GitHubCopilotCacheControl? public init( role: MessageRole, @@ -464,7 +475,8 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet name: String? = nil, tool_calls: [MessageToolCall]? = nil, tool_call_id: String? = nil, - function_call: MessageFunctionCall? = nil + function_call: MessageFunctionCall? = nil, + copilot_cache_control: GitHubCopilotCacheControl? = nil ) { self.role = role self.content = content @@ -472,6 +484,7 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet self.tool_calls = tool_calls self.tool_call_id = tool_call_id self.function_call = function_call + self.copilot_cache_control = copilot_cache_control } } @@ -623,6 +636,7 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet Self.setupCustomBody(&request, model: model) Self.setupAppInformation(&request) Self.setupAPIKey(&request, model: model, apiKey: apiKey) + Self.setupGitHubCopilotVisionField(&request, model: model) await Self.setupExtraHeaderFields(&request, model: model, apiKey: apiKey) requestModifier?(&request) @@ -771,6 +785,13 @@ public actor OpenAIChatCompletionsService: ChatCompletionsStreamAPI, ChatComplet } } } + + static func setupGitHubCopilotVisionField(_ request: inout URLRequest, model: ChatModel) { + guard model.format == .gitHubCopilot else { return } + if model.info.supportsImage { + request.setValue("true", forHTTPHeaderField: "copilot-vision-request") + } + } static func setupCustomBody(_ request: inout URLRequest, model: ChatModel) { switch model.format { @@ -1242,12 +1263,17 @@ extension OpenAIChatCompletionsService.RequestBody { } }(), content: { + // always prefer text only content if possible. if supportsMultipartMessageContent { - return .contentParts(Self.convertContentPart( - content: message.content, - images: supportsImage ? message.images : [], - audios: supportsAudio ? message.audios : [] - )) + let images = supportsImage ? message.images : [] + let audios = supportsAudio ? message.audios : [] + if !images.isEmpty || !audios.isEmpty { + return .contentParts(Self.convertContentPart( + content: message.content, + images: images, + audios: audios + )) + } } return .text(message.content) }(), From c6d420183312ff78ee2a607e417dcac6e417268d Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 Oct 2025 16:09:14 +0800 Subject: [PATCH 18/19] Adjust UI --- Core/Sources/SuggestionWidget/ChatWindowView.swift | 4 +++- .../CodeBlockSuggestionPanelView.swift | 9 +++++++-- Tool/Sources/SharedUIComponents/XcodeStyleFrame.swift | 8 ++++++-- Version.xcconfig | 2 +- 4 files changed, 17 insertions(+), 6 deletions(-) diff --git a/Core/Sources/SuggestionWidget/ChatWindowView.swift b/Core/Sources/SuggestionWidget/ChatWindowView.swift index 2144100c..58c6f4d7 100644 --- a/Core/Sources/SuggestionWidget/ChatWindowView.swift +++ b/Core/Sources/SuggestionWidget/ChatWindowView.swift @@ -4,6 +4,7 @@ import ChatGPTChatTab import ChatTab import ComposableArchitecture import SwiftUI +import SharedUIComponents private let r: Double = 8 @@ -21,13 +22,14 @@ struct ChatWindowView: View { ChatTabBar(store: store) .frame(height: 26) + .clipped() Divider() ChatTabContainer(store: store) .frame(maxWidth: .infinity, maxHeight: .infinity) } - .xcodeStyleFrame(cornerRadius: 10) + .xcodeStyleFrame() .ignoresSafeArea(edges: .top) .onChange(of: store.isPanelDisplayed) { isDisplayed in toggleVisibility(isDisplayed) diff --git a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift index 3c0d8da0..e876728f 100644 --- a/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift +++ b/Core/Sources/SuggestionWidget/SuggestionPanelContent/CodeBlockSuggestionPanelView.swift @@ -277,8 +277,13 @@ struct CodeBlockSuggestionPanelView: View { } .xcodeStyleFrame(cornerRadius: { switch suggestionPresentationMode { - case .nearbyTextCursor: 6 - case .floatingWidget: nil + case .nearbyTextCursor: + if #available(macOS 26.0, *) { + return 8 + } else { + return 6 + } + case .floatingWidget: return nil } }()) } diff --git a/Tool/Sources/SharedUIComponents/XcodeStyleFrame.swift b/Tool/Sources/SharedUIComponents/XcodeStyleFrame.swift index 53f2a395..d3f715ea 100644 --- a/Tool/Sources/SharedUIComponents/XcodeStyleFrame.swift +++ b/Tool/Sources/SharedUIComponents/XcodeStyleFrame.swift @@ -24,7 +24,7 @@ public struct XcodeLikeFrame: View { ) // Add an extra border just incase the background is not displayed. .overlay( RoundedRectangle(cornerRadius: max(0, cornerRadius - 1), style: .continuous) - .stroke(Color.white.opacity(0.2), style: .init(lineWidth: 1)) + .stroke(Color.white.opacity(0.1), style: .init(lineWidth: 1)) .padding(1) ) } @@ -32,7 +32,11 @@ public struct XcodeLikeFrame: View { public extension View { func xcodeStyleFrame(cornerRadius: Double? = nil) -> some View { - XcodeLikeFrame(cornerRadius: cornerRadius ?? 10, content: self) + if #available(macOS 26.0, *) { + XcodeLikeFrame(cornerRadius: cornerRadius ?? 14, content: self) + } else { + XcodeLikeFrame(cornerRadius: cornerRadius ?? 10, content: self) + } } } diff --git a/Version.xcconfig b/Version.xcconfig index ddbce34a..901afbed 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,4 +1,4 @@ APP_VERSION = 0.36.0 -APP_BUILD = 474 +APP_BUILD = 476 RELEASE_CHANNEL = RELEASE_NUMBER = 1 From a346816ea5a9a4fb4b9ca345a57207d577ac0760 Mon Sep 17 00:00:00 2001 From: Shx Guo Date: Thu, 23 Oct 2025 20:47:25 +0800 Subject: [PATCH 19/19] Update --- Core/Sources/HostApp/FeatureSettingsView.swift | 2 +- Version.xcconfig | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Core/Sources/HostApp/FeatureSettingsView.swift b/Core/Sources/HostApp/FeatureSettingsView.swift index 03efb50c..e8c1e38f 100644 --- a/Core/Sources/HostApp/FeatureSettingsView.swift +++ b/Core/Sources/HostApp/FeatureSettingsView.swift @@ -43,7 +43,7 @@ struct FeatureSettingsView: View { tag: 3, title: "Xcode", subtitle: "Xcode related features", - image: "app" + image: "hammer.circle" ) ForEach(Array(tabContainer.tabs.enumerated()), id: \.1.id) { index, tab in diff --git a/Version.xcconfig b/Version.xcconfig index 901afbed..72a379cd 100644 --- a/Version.xcconfig +++ b/Version.xcconfig @@ -1,4 +1,4 @@ APP_VERSION = 0.36.0 -APP_BUILD = 476 +APP_BUILD = 477 RELEASE_CHANNEL = RELEASE_NUMBER = 1