From af7e66d1712988102b0bb957f21d2cc7c18ddbb5 Mon Sep 17 00:00:00 2001 From: lifejwang11 <13122192336@163.com> Date: Wed, 22 Apr 2026 14:20:39 +0800 Subject: [PATCH 1/2] feat: add AI Chat Panel Add a collapsible AI chat panel integrated into the main terminal layout. - Add ChatService.swift: multi-provider AI service supporting Gemini, GPT, Claude, OpenRouter, DeepSeek, Qwen, and MiniMax with streaming responses - Add ChatPanelView.swift: SwiftUI chat panel with message history, settings sheet for API key/model/provider configuration, and hide button - Integrate ChatPanelView into ContentView alongside the terminal area - Add 'Show/Hide AI Chat Panel' menu item under View (Cmd+Shift+\) - Persist panel visibility via @AppStorage("chatPanelVisible") Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- GhosttyTabs.xcodeproj/project.pbxproj | 8 + Sources/AppDelegate.swift | 3 + Sources/ChatPanelView.swift | 572 ++++++++++++++++++++++++++ Sources/ChatService.swift | 449 ++++++++++++++++++++ Sources/ContentView.swift | 26 +- Sources/GhosttyTerminalView.swift | 7 + Sources/TerminalWindowPortal.swift | 5 + Sources/cmuxApp.swift | 10 + 8 files changed, 1078 insertions(+), 2 deletions(-) create mode 100644 Sources/ChatPanelView.swift create mode 100644 Sources/ChatService.swift diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index ab8673d1f..ba4d6fbbe 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -37,6 +37,8 @@ A5007420 /* BrowserPopupWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5007421 /* BrowserPopupWindowController.swift */; }; A5001420 /* MarkdownPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001418 /* MarkdownPanel.swift */; }; A5001421 /* MarkdownPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001419 /* MarkdownPanelView.swift */; }; + A5001424 /* ChatService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001422 /* ChatService.swift */; }; + A5001425 /* ChatPanelView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001423 /* ChatPanelView.swift */; }; A5001290 /* MarkdownUI in Frameworks */ = {isa = PBXBuildFile; productRef = A5001291 /* MarkdownUI */; }; A5001405 /* PanelContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001415 /* PanelContentView.swift */; }; A5001406 /* Workspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001416 /* Workspace.swift */; }; @@ -241,6 +243,8 @@ A5001415 /* PanelContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/PanelContentView.swift; sourceTree = ""; }; A5001418 /* MarkdownPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanel.swift; sourceTree = ""; }; A5001419 /* MarkdownPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Panels/MarkdownPanelView.swift; sourceTree = ""; }; + A5001422 /* ChatService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatService.swift; sourceTree = ""; }; + A5001423 /* ChatPanelView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ChatPanelView.swift; sourceTree = ""; }; A5001416 /* Workspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Workspace.swift; sourceTree = ""; }; A5001417 /* WorkspaceContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkspaceContentView.swift; sourceTree = ""; }; A5001090 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; @@ -458,6 +462,8 @@ children = ( A5001011 /* cmuxApp.swift */, A5001012 /* ContentView.swift */, + A5001422 /* ChatService.swift */, + A5001423 /* ChatPanelView.swift */, 9AD52285508B1D6A9875E7B3 /* SidebarSelectionState.swift */, B9000017A1B2C3D4E5F60719 /* WindowDragHandleView.swift */, A50012F0 /* Backport.swift */, @@ -821,6 +827,8 @@ A5007420 /* BrowserPopupWindowController.swift in Sources */, A5001420 /* MarkdownPanel.swift in Sources */, A5001421 /* MarkdownPanelView.swift in Sources */, + A5001424 /* ChatService.swift in Sources */, + A5001425 /* ChatPanelView.swift in Sources */, A5001500 /* CmuxWebView.swift in Sources */, A5001405 /* PanelContentView.swift in Sources */, A5001201 /* UpdateController.swift in Sources */, diff --git a/Sources/AppDelegate.swift b/Sources/AppDelegate.swift index eba3d12d8..1dc738d84 100644 --- a/Sources/AppDelegate.swift +++ b/Sources/AppDelegate.swift @@ -5696,6 +5696,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if hostedView.responderMatchesPreferredKeyboardFocus(responder) { return false } + if ChatPanelHitTestRegistry.ownsFocusResponder(responder) { + return false + } return true } diff --git a/Sources/ChatPanelView.swift b/Sources/ChatPanelView.swift new file mode 100644 index 000000000..1aac4ddbb --- /dev/null +++ b/Sources/ChatPanelView.swift @@ -0,0 +1,572 @@ +import SwiftUI +import AppKit + +struct ChatPanelView: View { + @ObservedObject var chatService: ChatService + let onHide: () -> Void + + @State private var inputText = "" + @State private var apiKeyDraft = "" + @State private var settingsProvider: ChatService.Provider = .claude + @State private var modelDraft = "" + @State private var isShowingSettings = false + @FocusState private var isInputFocused: Bool + @FocusState private var isApiKeyFocused: Bool + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(spacing: 0) { + header + Divider() + conversationView + } + .background(panelBackground) + .frame(width: ChatPanelMetrics.panelWidth) + .background(ChatPanelHitTestMarker()) + .sheet(isPresented: $isShowingSettings) { + settingsSheet + } + .onAppear { + settingsProvider = chatService.selectedProvider + apiKeyDraft = chatService.apiKey + modelDraft = chatService.selectedModel + } + } + + // MARK: - Header + + private var header: some View { + HStack(spacing: 8) { + Image(systemName: "bubble.left.and.bubble.right") + .foregroundStyle(.secondary) + .font(.system(size: 13)) + Text(String(localized: "chatPanel.title", defaultValue: "AI Chat")) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.primary) + Text(chatService.selectedProvider.displayName) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + Spacer() + Button { + chatService.clearMessages() + } label: { + Image(systemName: "trash") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(String(localized: "chatPanel.clearChat", defaultValue: "Clear Chat")) + + Button { + openSettings(for: chatService.selectedProvider) + } label: { + Image(systemName: "gearshape") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(String(localized: "chatPanel.settings", defaultValue: "Chat Settings")) + + Button { + onHide() + } label: { + Image(systemName: "sidebar.right") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(String(localized: "chatPanel.hide", defaultValue: "Hide Chat")) + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + } + + // MARK: - Settings + + private var settingsSheet: some View { + VStack(alignment: .leading, spacing: 14) { + HStack { + Text(String(localized: "chatPanel.settings.title", defaultValue: "Chat Settings")) + .font(.headline) + Spacer() + } + + ScrollView(.horizontal, showsIndicators: false) { + Picker(String(localized: "chatPanel.provider", defaultValue: "Provider"), selection: $settingsProvider) { + ForEach(ChatService.Provider.allCases) { provider in + Text(provider.displayName).tag(provider) + } + } + .pickerStyle(.segmented) + .fixedSize(horizontal: true, vertical: false) + } + .onChange(of: settingsProvider) { _, provider in + apiKeyDraft = chatService.apiKey(for: provider) + modelDraft = chatService.model(for: provider) + } + + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "chatPanel.apiKey.label", defaultValue: "API Key")) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + SecureField(settingsProvider.apiKeyPlaceholder, text: $apiKeyDraft) + .textFieldStyle(.roundedBorder) + .focused($isApiKeyFocused) + } + + VStack(alignment: .leading, spacing: 6) { + Text(String(localized: "chatPanel.model.label", defaultValue: "Model")) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.secondary) + Picker(String(localized: "chatPanel.model.label", defaultValue: "Model"), selection: $modelDraft) { + ForEach(chatService.modelOptions(for: settingsProvider), id: \.self) { model in + Text(model).tag(model) + } + } + .pickerStyle(.menu) + .frame(maxWidth: .infinity, alignment: .leading) + } + + HStack(spacing: 8) { + Spacer() + Button(String(localized: "common.cancel", defaultValue: "Cancel")) { + isShowingSettings = false + } + .buttonStyle(.bordered) + + Button(String(localized: "chatPanel.apiKey.save", defaultValue: "Save")) { + let trimmed = apiKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines) + chatService.selectedProvider = settingsProvider + chatService.setApiKey(trimmed, for: settingsProvider) + chatService.setModel(modelDraft, for: settingsProvider) + apiKeyDraft = "" + isShowingSettings = false + } + .buttonStyle(.borderedProminent) + .disabled(apiKeyDraft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + .padding(20) + .frame(minWidth: 360, idealWidth: 520, maxWidth: 720) + .frame(minHeight: 250) + } + + // MARK: - Conversation + + private var conversationView: some View { + VStack(spacing: 0) { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(alignment: .leading, spacing: 12) { + if chatService.messages.isEmpty { + emptyState + } else { + ForEach(chatService.messages) { message in + MessageBubble(message: message, colorScheme: colorScheme) + .id(message.id) + } + } + if let error = chatService.streamingError { + errorBanner(error) + } + Color.clear.frame(height: 1).id("bottom") + } + .padding(.horizontal, 12) + .padding(.vertical, 12) + } + .onChange(of: chatService.messages.count) { _, _ in + withAnimation(.easeOut(duration: 0.15)) { + proxy.scrollTo("bottom", anchor: .bottom) + } + } + .onChange(of: chatService.messages.last?.content) { _, _ in + proxy.scrollTo("bottom", anchor: .bottom) + } + } + + Divider() + inputArea + } + } + + private func errorBanner(_ text: String) -> some View { + HStack(spacing: 6) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(.red) + .font(.system(size: 12)) + Text(text) + .font(.system(size: 12)) + .foregroundStyle(.red) + .fixedSize(horizontal: false, vertical: true) + } + .padding(10) + .background(Color.red.opacity(0.08), in: RoundedRectangle(cornerRadius: 8)) + .frame(maxWidth: .infinity, alignment: .leading) + } + + // MARK: - Input area + + private var inputArea: some View { + VStack(spacing: 6) { + HStack(alignment: .bottom, spacing: 8) { + ZStack(alignment: .topLeading) { + ChatTextEditor(text: $inputText, onSubmit: sendMessage) + if inputText.isEmpty { + Text(inputPlaceholder) + .font(.system(size: 13)) + .foregroundStyle(Color(white: 0.55)) + .padding(.leading, 10) + .padding(.top, 8) + .allowsHitTesting(false) + } + } + .frame(minHeight: 34, maxHeight: 120) + .focused($isInputFocused) + + if chatService.isStreaming { + Button { + chatService.cancelStreaming() + } label: { + Image(systemName: "stop.circle.fill") + .font(.system(size: 20)) + .foregroundStyle(.red.opacity(0.8)) + } + .buttonStyle(.plain) + } else { + Button { + sendMessage() + } label: { + Image(systemName: "arrow.up.circle.fill") + .font(.system(size: 20)) + .foregroundStyle( + inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + ? Color.secondary.opacity(0.4) + : cmuxAccentColor() + ) + } + .buttonStyle(.plain) + .disabled(inputText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } + } + + providerAndModelPicker + } + .padding(.horizontal, 10) + .padding(.vertical, 8) + } + + private var providerAndModelPicker: some View { + HStack(spacing: 6) { + Image(systemName: chatService.hasApiKey ? "sparkles" : "key") + .font(.system(size: 10)) + .foregroundStyle(.secondary) + Picker(String(localized: "chatPanel.provider", defaultValue: "Provider"), selection: Binding( + get: { chatService.selectedProvider }, + set: { provider in + chatService.selectedProvider = provider + if !chatService.hasApiKey { + openSettings(for: provider, focusApiKey: true) + } + } + )) { + ForEach(ChatService.Provider.allCases) { provider in + Text(provider.displayName).tag(provider) + } + } + .labelsHidden() + .pickerStyle(.menu) + .controlSize(.small) + .disabled(chatService.isStreaming) + + Picker(String(localized: "chatPanel.model.label", defaultValue: "Model"), selection: Binding( + get: { chatService.selectedModel }, + set: { chatService.setModel($0, for: chatService.selectedProvider) } + )) { + ForEach(chatService.modelOptions(for: chatService.selectedProvider), id: \.self) { model in + Text(model).tag(model) + } + } + .labelsHidden() + .pickerStyle(.menu) + .controlSize(.small) + .disabled(chatService.isStreaming) + Spacer() + } + .frame(height: 22) + } + + private var emptyState: some View { + VStack(spacing: 10) { + Image(systemName: chatService.hasApiKey ? "sparkles" : "key") + .font(.system(size: 28)) + .foregroundStyle(.secondary) + Text( + chatService.hasApiKey + ? String(localized: "chatPanel.empty.ready", defaultValue: "Ask \(chatService.selectedProvider.displayName) anything.") + : String(localized: "chatPanel.empty.needsKey", defaultValue: "Choose a provider and add an API key to start chatting.") + ) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + if !chatService.hasApiKey { + Button(String(localized: "chatPanel.settings.open", defaultValue: "Open Settings")) { + openSettings(for: chatService.selectedProvider, focusApiKey: true) + } + .buttonStyle(.bordered) + } + } + .padding(.horizontal, 18) + .padding(.vertical, 28) + .frame(maxWidth: .infinity) + } + + private var inputPlaceholder: String { + if chatService.hasApiKey { + return String(localized: "chatPanel.input.placeholder", defaultValue: "Message...") + } + return String(localized: "chatPanel.input.needsKey", defaultValue: "Add an API key in settings...") + } + + private func sendMessage() { + guard chatService.hasApiKey else { + openSettings(for: chatService.selectedProvider, focusApiKey: true) + return + } + let text = inputText + inputText = "" + chatService.sendMessage(text) + } + + private func openSettings(for provider: ChatService.Provider, focusApiKey: Bool = false) { + settingsProvider = provider + apiKeyDraft = chatService.apiKey(for: provider) + modelDraft = chatService.model(for: provider) + isShowingSettings = true + guard focusApiKey else { return } + DispatchQueue.main.async { + isApiKeyFocused = true + } + } + + // MARK: - Background + + private var panelBackground: Color { + colorScheme == .dark + ? Color(nsColor: NSColor(white: 0.12, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.97, alpha: 1.0)) + } +} + +// MARK: - Hit Test Marker + +enum ChatPanelHitTestRegistry { + private struct MarkerEntry { + let windowId: ObjectIdentifier + let rectInWindow: NSRect + } + + private static var entriesByMarkerId: [ObjectIdentifier: MarkerEntry] = [:] + private static var focusOwnerTable: NSHashTable = .weakObjects() + + static func update(marker: NSView) { + let markerId = ObjectIdentifier(marker) + guard let window = marker.window else { + entriesByMarkerId.removeValue(forKey: markerId) + return + } + let rect = marker.convert(marker.bounds, to: nil) + entriesByMarkerId[markerId] = MarkerEntry( + windowId: ObjectIdentifier(window), + rectInWindow: rect + ) + } + + static func remove(marker: NSView) { + entriesByMarkerId.removeValue(forKey: ObjectIdentifier(marker)) + } + + static func contains(windowPoint: NSPoint, in window: NSWindow?) -> Bool { + guard let window else { return false } + let windowId = ObjectIdentifier(window) + return entriesByMarkerId.values.contains { + $0.windowId == windowId && $0.rectInWindow.contains(windowPoint) + } + } + + static func registerFocusOwner(_ view: NSView) { + focusOwnerTable.add(view) + } + + static func unregisterFocusOwner(_ view: NSView) { + focusOwnerTable.remove(view) + } + + /// Returns true if the responder is (or is a descendant of) a chat panel input view. + static func ownsFocusResponder(_ responder: NSResponder) -> Bool { + guard let view = responder as? NSView else { return false } + for owner in focusOwnerTable.allObjects { + if view === owner || view.isDescendant(of: owner) { + return true + } + } + return false + } +} + +private struct ChatPanelHitTestMarker: NSViewRepresentable { + func makeNSView(context: Context) -> MarkerView { + MarkerView(frame: .zero) + } + + func updateNSView(_ nsView: MarkerView, context: Context) { + nsView.refreshRegisteredFrame() + } + + final class MarkerView: NSView { + override var isOpaque: Bool { false } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + refreshRegisteredFrame() + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + refreshRegisteredFrame() + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + refreshRegisteredFrame() + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + deinit { + ChatPanelHitTestRegistry.remove(marker: self) + } + + func refreshRegisteredFrame() { + ChatPanelHitTestRegistry.update(marker: self) + } + } +} + +// MARK: - Message Bubble + +private struct MessageBubble: View { + let message: ChatService.Message + let colorScheme: ColorScheme + + var isUser: Bool { message.role == .user } + + var body: some View { + HStack { + if isUser { Spacer(minLength: 32) } + Text(message.content.isEmpty && !isUser ? "▌" : message.content) + .font(.system(size: 13)) + .foregroundStyle(isUser ? Color.white : (colorScheme == .dark ? Color.white.opacity(0.9) : Color.primary)) + .textSelection(.enabled) + .padding(.horizontal, 10) + .padding(.vertical, 7) + .background( + isUser + ? cmuxAccentColor() + : (colorScheme == .dark + ? Color(nsColor: NSColor(white: 0.22, alpha: 1.0)) + : Color(nsColor: NSColor(white: 0.90, alpha: 1.0))), + in: RoundedRectangle(cornerRadius: 12) + ) + .fixedSize(horizontal: false, vertical: true) + if !isUser { Spacer(minLength: 32) } + } + .frame(maxWidth: .infinity, alignment: isUser ? .trailing : .leading) + } +} + +// MARK: - Chat Text Editor + +private struct ChatTextEditor: NSViewRepresentable { + @Binding var text: String + let onSubmit: () -> Void + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollablePlainDocumentContentTextView() + guard let textView = scrollView.documentView as? NSTextView else { return scrollView } + + textView.delegate = context.coordinator + textView.isRichText = false + textView.font = NSFont.systemFont(ofSize: 13) + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticSpellingCorrectionEnabled = true + textView.textContainerInset = NSSize(width: 4, height: 6) + scrollView.hasVerticalScroller = true + scrollView.hasHorizontalScroller = false + scrollView.autohidesScrollers = true + scrollView.borderType = .bezelBorder + + ChatPanelHitTestRegistry.registerFocusOwner(scrollView) + return scrollView + } + + static func dismantleNSView(_ nsView: NSScrollView, coordinator: Coordinator) { + ChatPanelHitTestRegistry.unregisterFocusOwner(nsView) + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + guard let textView = scrollView.documentView as? NSTextView else { return } + context.coordinator.onSubmit = onSubmit + + let isDark = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + let bgColor = isDark ? NSColor(white: 0.18, alpha: 1.0) : NSColor.white + textView.backgroundColor = bgColor + scrollView.backgroundColor = bgColor + scrollView.drawsBackground = true + + if textView.string != text { + let loc = min(textView.selectedRange().location, (text as NSString).length) + textView.string = text + textView.setSelectedRange(NSRange(location: loc, length: 0)) + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(text: $text, onSubmit: onSubmit) + } + + final class Coordinator: NSObject, NSTextViewDelegate { + @Binding var text: String + var onSubmit: () -> Void + + init(text: Binding, onSubmit: @escaping () -> Void) { + _text = text + self.onSubmit = onSubmit + } + + func textDidChange(_ notification: Notification) { + guard let textView = notification.object as? NSTextView else { return } + text = textView.string + } + + func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { + if commandSelector == #selector(NSResponder.insertNewline(_:)) { + let shiftDown = NSEvent.modifierFlags.contains(.shift) + if !shiftDown { + onSubmit() + return true + } + } + return false + } + } +} + +enum ChatPanelMetrics { + static let panelWidth: CGFloat = 420 + static let minPanelWidth: CGFloat = 320 + static let maxPanelWidth: CGFloat = 480 +} diff --git a/Sources/ChatService.swift b/Sources/ChatService.swift new file mode 100644 index 000000000..42e81b2a8 --- /dev/null +++ b/Sources/ChatService.swift @@ -0,0 +1,449 @@ +import Foundation + +@MainActor +final class ChatService: ObservableObject { + static let shared = ChatService() + + enum Provider: String, CaseIterable, Identifiable { + case gemini + case gpt + case claude + case openrouter + case deepseek + case qwen + case minimax + + var id: String { rawValue } + + var displayName: String { + switch self { + case .gemini: + return "Gemini" + case .gpt: + return "GPT" + case .claude: + return "Claude" + case .openrouter: + return "OpenRouter" + case .deepseek: + return "DeepSeek" + case .qwen: + return "Qwen" + case .minimax: + return "MiniMax" + } + } + + var apiKeyDefaultsKey: String { + switch self { + case .gemini: + return "chatProviderGeminiAPIKey" + case .gpt: + return "chatProviderOpenAIAPIKey" + case .claude: + return "chatProviderAnthropicAPIKey" + case .openrouter: + return "chatProviderOpenRouterAPIKey" + case .deepseek: + return "chatProviderDeepSeekAPIKey" + case .qwen: + return "chatProviderQwenAPIKey" + case .minimax: + return "chatProviderMiniMaxAPIKey" + } + } + + var defaultModel: String { + switch self { + case .gemini: + return "gemini-2.0-flash" + case .gpt: + return "gpt-4o-mini" + case .claude: + return "claude-3-5-sonnet-latest" + case .openrouter: + return "openai/gpt-4o-mini" + case .deepseek: + return "deepseek-chat" + case .qwen: + return "qwen-plus" + case .minimax: + return "MiniMax-M2.7" + } + } + + var modelDefaultsKey: String { + "chatProviderModel.\(rawValue)" + } + + var modelOptions: [String] { + switch self { + case .gemini: + return ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash"] + case .gpt: + return ["gpt-4o-mini", "gpt-4o", "gpt-4.1-mini", "gpt-4.1"] + case .claude: + return ["claude-3-5-sonnet-latest", "claude-3-5-haiku-latest", "claude-3-opus-latest"] + case .openrouter: + return ["openai/gpt-4o-mini", "openai/gpt-4o", "anthropic/claude-3.5-sonnet", "google/gemini-flash-1.5"] + case .deepseek: + return ["deepseek-chat", "deepseek-reasoner"] + case .qwen: + return ["qwen-plus", "qwen-max", "qwen-turbo", "qwen3-max", "qwen3-coder-plus"] + case .minimax: + return ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"] + } + } + + var apiKeyPlaceholder: String { + switch self { + case .gemini: + return "AIza..." + case .gpt: + return "sk-..." + case .claude: + return "sk-ant-..." + case .openrouter: + return "sk-or-..." + case .deepseek: + return "sk-..." + case .qwen: + return "sk-..." + case .minimax: + return "eyJ..." + } + } + } + + struct Message: Identifiable, Equatable { + let id: UUID + let role: Role + var content: String + + enum Role: String { + case user + case assistant + } + + init(role: Role, content: String) { + self.id = UUID() + self.role = role + self.content = content + } + } + + private static let selectedProviderDefaultsKey = "chatSelectedProvider" + + @Published private(set) var messages: [Message] = [] + @Published private(set) var isStreaming = false + @Published private(set) var streamingError: String? + @Published var selectedProvider: Provider { + didSet { + UserDefaults.standard.set(selectedProvider.rawValue, forKey: Self.selectedProviderDefaultsKey) + } + } + + private var streamTask: Task? + + init() { + let rawProvider = UserDefaults.standard.string(forKey: Self.selectedProviderDefaultsKey) ?? Provider.claude.rawValue + selectedProvider = Provider(rawValue: rawProvider) ?? .claude + migrateLegacyAnthropicKeyIfNeeded() + } + + var apiKey: String { + get { apiKey(for: selectedProvider) } + set { setApiKey(newValue, for: selectedProvider) } + } + + var selectedModel: String { + get { model(for: selectedProvider) } + set { setModel(newValue, for: selectedProvider) } + } + + var hasApiKey: Bool { !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } + + func apiKey(for provider: Provider) -> String { + UserDefaults.standard.string(forKey: provider.apiKeyDefaultsKey) ?? "" + } + + func setApiKey(_ key: String, for provider: Provider) { + UserDefaults.standard.set(key, forKey: provider.apiKeyDefaultsKey) + objectWillChange.send() + } + + func model(for provider: Provider) -> String { + let saved = UserDefaults.standard.string(forKey: provider.modelDefaultsKey) ?? "" + return saved.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? provider.defaultModel : saved + } + + func setModel(_ model: String, for provider: Provider) { + let trimmed = model.trimmingCharacters(in: .whitespacesAndNewlines) + UserDefaults.standard.set(trimmed.isEmpty ? provider.defaultModel : trimmed, forKey: provider.modelDefaultsKey) + objectWillChange.send() + } + + func modelOptions(for provider: Provider) -> [String] { + let selected = model(for: provider) + guard !provider.modelOptions.contains(selected) else { return provider.modelOptions } + return [selected] + provider.modelOptions + } + + func clearMessages() { + messages = [] + streamingError = nil + } + + func sendMessage(_ text: String) { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty, !isStreaming else { return } + guard hasApiKey else { + streamingError = String(localized: "chatPanel.error.missingApiKey", defaultValue: "Choose a provider and enter an API key in chat settings.") + return + } + + streamingError = nil + messages.append(Message(role: .user, content: trimmed)) + + isStreaming = true + let sendMessages = messages + let provider = selectedProvider + let key = apiKey + let model = selectedModel + + streamTask = Task { + var assistantContent = "" + var appendedAssistant = false + + do { + let request = try Self.buildRequest(messages: sendMessages, provider: provider, apiKey: key, model: model) + let (bytes, response) = try await URLSession.shared.bytes(for: request) + + guard let http = response as? HTTPURLResponse else { + throw ChatAPIError.invalidResponse + } + guard (200..<300).contains(http.statusCode) else { + var body = "" + for try await line in bytes.lines { body += line } + throw ChatAPIError.httpError(http.statusCode, body) + } + + for try await line in bytes.lines { + if Task.isCancelled { break } + guard line.hasPrefix("data: ") else { continue } + let data = String(line.dropFirst(6)) + if data == "[DONE]" { break } + guard let chunk = Self.parseSSEChunk(data, provider: provider) else { continue } + + assistantContent += chunk + if !appendedAssistant { + messages.append(Message(role: .assistant, content: assistantContent)) + appendedAssistant = true + } else if let idx = messages.indices.last { + messages[idx].content = assistantContent + } + } + + if !appendedAssistant { + messages.append(Message(role: .assistant, content: assistantContent)) + } + } catch { + if !Task.isCancelled { + streamingError = (error as? ChatAPIError)?.errorDescription ?? error.localizedDescription + } + if assistantContent.isEmpty && appendedAssistant { + messages.removeLast() + } + } + + isStreaming = false + } + } + + func cancelStreaming() { + streamTask?.cancel() + streamTask = nil + isStreaming = false + } + + private func migrateLegacyAnthropicKeyIfNeeded() { + let legacyKey = UserDefaults.standard.string(forKey: "anthropicAPIKey") ?? "" + guard !legacyKey.isEmpty, apiKey(for: .claude).isEmpty else { return } + setApiKey(legacyKey, for: .claude) + } + + private static func buildRequest(messages: [Message], provider: Provider, apiKey: String, model: String) throws -> URLRequest { + switch provider { + case .gemini: + return try buildGeminiRequest(messages: messages, apiKey: apiKey, model: model) + case .gpt: + return try buildOpenAICompatibleRequest( + url: URL(string: "https://api.openai.com/v1/chat/completions")!, + messages: messages, + apiKey: apiKey, + model: model, + extraHeaders: [:] + ) + case .claude: + return try buildClaudeRequest(messages: messages, apiKey: apiKey, model: model) + case .openrouter: + return try buildOpenAICompatibleRequest( + url: URL(string: "https://openrouter.ai/api/v1/chat/completions")!, + messages: messages, + apiKey: apiKey, + model: model, + extraHeaders: [ + "HTTP-Referer": "https://cmux.local", + "X-Title": "cmux" + ] + ) + case .deepseek: + return try buildOpenAICompatibleRequest( + url: URL(string: "https://api.deepseek.com/chat/completions")!, + messages: messages, + apiKey: apiKey, + model: model, + extraHeaders: [:] + ) + case .qwen: + return try buildOpenAICompatibleRequest( + url: URL(string: "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions")!, + messages: messages, + apiKey: apiKey, + model: model, + extraHeaders: [:] + ) + case .minimax: + return try buildOpenAICompatibleRequest( + url: URL(string: "https://api.minimax.io/v1/chat/completions")!, + messages: messages, + apiKey: apiKey, + model: model, + extraHeaders: [:] + ) + } + } + + private static func buildOpenAICompatibleRequest( + url: URL, + messages: [Message], + apiKey: String, + model: String, + extraHeaders: [String: String] + ) throws -> URLRequest { + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("Bearer \(apiKey)", forHTTPHeaderField: "authorization") + request.setValue("application/json", forHTTPHeaderField: "content-type") + extraHeaders.forEach { request.setValue($0.value, forHTTPHeaderField: $0.key) } + + let body: [String: Any] = [ + "model": model, + "stream": true, + "messages": messages.map { ["role": $0.role.rawValue, "content": $0.content] } + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + return request + } + + private static func buildClaudeRequest(messages: [Message], apiKey: String, model: String) throws -> URLRequest { + var request = URLRequest(url: URL(string: "https://api.anthropic.com/v1/messages")!) + request.httpMethod = "POST" + request.setValue(apiKey, forHTTPHeaderField: "x-api-key") + request.setValue("2023-06-01", forHTTPHeaderField: "anthropic-version") + request.setValue("application/json", forHTTPHeaderField: "content-type") + + let body: [String: Any] = [ + "model": model, + "max_tokens": 4096, + "stream": true, + "messages": messages.map { ["role": $0.role.rawValue, "content": $0.content] } + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + return request + } + + private static func buildGeminiRequest(messages: [Message], apiKey: String, model: String) throws -> URLRequest { + var components = URLComponents(string: "https://generativelanguage.googleapis.com/v1beta/models/\(model):streamGenerateContent")! + components.queryItems = [ + URLQueryItem(name: "alt", value: "sse"), + URLQueryItem(name: "key", value: apiKey) + ] + + var request = URLRequest(url: components.url!) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "content-type") + + let body: [String: Any] = [ + "contents": messages.map { + [ + "role": $0.role == .assistant ? "model" : "user", + "parts": [["text": $0.content]] + ] + } + ] + request.httpBody = try JSONSerialization.data(withJSONObject: body) + return request + } + + private static func parseSSEChunk(_ data: String, provider: Provider) -> String? { + guard let json = decodeJSONObject(data) else { return nil } + + switch provider { + case .gemini: + return parseGeminiChunk(json) + case .gpt, .openrouter, .deepseek, .qwen, .minimax: + return parseOpenAICompatibleChunk(json) + case .claude: + return parseClaudeChunk(json) + } + } + + private static func decodeJSONObject(_ data: String) -> [String: Any]? { + guard let jsonData = data.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: jsonData) as? [String: Any] + } + + private static func parseOpenAICompatibleChunk(_ json: [String: Any]) -> String? { + guard let choices = json["choices"] as? [[String: Any]], + let delta = choices.first?["delta"] as? [String: Any], + let text = delta["content"] as? String else { + return nil + } + return text + } + + private static func parseClaudeChunk(_ json: [String: Any]) -> String? { + guard let type = json["type"] as? String, + type == "content_block_delta", + let delta = json["delta"] as? [String: Any], + let text = delta["text"] as? String else { + return nil + } + return text + } + + private static func parseGeminiChunk(_ json: [String: Any]) -> String? { + guard let candidates = json["candidates"] as? [[String: Any]], + let content = candidates.first?["content"] as? [String: Any], + let parts = content["parts"] as? [[String: Any]] else { + return nil + } + return parts.compactMap { $0["text"] as? String }.joined() + } +} + +enum ChatAPIError: LocalizedError { + case invalidResponse + case httpError(Int, String) + + var errorDescription: String? { + switch self { + case .invalidResponse: + return String(localized: "chatPanel.error.invalidResponse", defaultValue: "Invalid API response") + case .httpError(let code, let body): + let message = String(body.prefix(200)) + return String(localized: "chatPanel.error.http", defaultValue: "API error (\(code)): \(message)") + } + } +} diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index da5491e65..cc9cc6b35 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1799,6 +1799,8 @@ struct ContentView: View { @EnvironmentObject var sidebarState: SidebarState @EnvironmentObject var sidebarSelectionState: SidebarSelectionState @EnvironmentObject var cmuxConfigStore: CmuxConfigStore + @StateObject private var chatService = ChatService.shared + @AppStorage("chatPanelVisible") private var isChatPanelVisible: Bool = true @State private var sidebarWidth: CGFloat = 200 @State private var hoveredResizerHandles: Set = [] @State private var isResizerDragging = false @@ -2879,8 +2881,14 @@ struct ContentView: View { // This allows withinWindow blur to see the terminal content layout = AnyView( ZStack(alignment: .leading) { - terminalContentWithSidebarDropOverlay - .padding(.leading, sidebarState.isVisible ? sidebarWidth : 0) + HStack(spacing: 0) { + terminalContentWithSidebarDropOverlay + .padding(.leading, sidebarState.isVisible ? sidebarWidth : 0) + if isChatPanelVisible { + Divider() + chatPanelView + } + } if sidebarState.isVisible { sidebarView } @@ -2894,6 +2902,10 @@ struct ContentView: View { sidebarView } terminalContentWithSidebarDropOverlay + if isChatPanelVisible { + Divider() + chatPanelView + } } ) } @@ -2909,6 +2921,16 @@ struct ContentView: View { ) } + private var chatPanelView: some View { + ChatPanelView(chatService: chatService) { + isChatPanelVisible = false + } + } + + func toggleChatPanel() { + isChatPanelVisible.toggle() + } + var body: some View { var view = AnyView( contentAndSidebarLayout diff --git a/Sources/GhosttyTerminalView.swift b/Sources/GhosttyTerminalView.swift index 0c8f1a92c..e412065c0 100644 --- a/Sources/GhosttyTerminalView.swift +++ b/Sources/GhosttyTerminalView.swift @@ -10578,6 +10578,13 @@ final class GhosttySurfaceScrollView: NSView { if let fr = window.firstResponder, isSearchOverlayOrDescendant(fr) { #if DEBUG dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=searchOverlayFocused") +#endif + return + } + // Don't steal focus from the chat panel input. + if let fr = window.firstResponder, ChatPanelHitTestRegistry.ownsFocusResponder(fr) { +#if DEBUG + dlog("find.applyFirstResponder SKIP surface=\(surfaceShort) reason=chatPanelFocused") #endif return } diff --git a/Sources/TerminalWindowPortal.swift b/Sources/TerminalWindowPortal.swift index 2bdc2398f..e1beb9342 100644 --- a/Sources/TerminalWindowPortal.swift +++ b/Sources/TerminalWindowPortal.swift @@ -146,6 +146,11 @@ final class WindowTerminalHostView: NSView { } if isPointerEvent { + if ChatPanelHitTestRegistry.contains(windowPoint: convert(point, to: nil), in: window) { + clearActiveDividerCursor(restoreArrow: true) + return nil + } + if shouldPassThroughToSidebarResizer(at: point) { clearActiveDividerCursor(restoreArrow: false) return nil diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index bdfc11798..2a4e8f2ae 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -674,6 +674,16 @@ struct cmuxApp: App { } } + Button( + UserDefaults.standard.bool(forKey: "chatPanelVisible") + ? String(localized: "menu.view.hideChatPanel", defaultValue: "Hide AI Chat Panel") + : String(localized: "menu.view.showChatPanel", defaultValue: "Show AI Chat Panel") + ) { + let current = UserDefaults.standard.object(forKey: "chatPanelVisible") as? Bool ?? true + UserDefaults.standard.set(!current, forKey: "chatPanelVisible") + } + .keyboardShortcut("\\", modifiers: [.command, .shift]) + Divider() splitCommandButton(title: String(localized: "menu.view.nextSurface", defaultValue: "Next Surface"), shortcut: menuShortcut(for: .nextSurface)) { From ff8f0fb068a7ce98642d6d84f979f4ab1e40fd7f Mon Sep 17 00:00:00 2001 From: lifejwang11 <13122192336@163.com> Date: Wed, 22 Apr 2026 14:37:23 +0800 Subject: [PATCH 2/2] fix: address PR review issues - Store API keys in macOS Keychain instead of plaintext UserDefaults - Send Gemini API key via x-goog-api-key header instead of URL query param - Make Show/Hide AI Chat Panel menu label reactive via @AppStorage - Route chat panel shortcut through KeyboardShortcutSettings (toggleAIChatPanel) - Guard isStreaming against stale canceled task by tracking stream ID - Switch ContentView from @StateObject to @ObservedObject for singleton - Add all chatPanel.* and related localization keys to Localizable.xcstrings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- Resources/Localizable.xcstrings | 220 +++++++++++++++++++++++++ Sources/ChatService.swift | 42 ++++- Sources/ContentView.swift | 2 +- Sources/KeyboardShortcutSettings.swift | 7 +- Sources/cmuxApp.swift | 18 +- 5 files changed, 274 insertions(+), 15 deletions(-) diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index cea9379bd..767c31594 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -87527,6 +87527,226 @@ } } } + }, + "chatPanel.apiKey.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "API Key" + } + } + } + }, + "chatPanel.apiKey.save": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Save" + } + } + } + }, + "chatPanel.clearChat": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Clear Chat" + } + } + } + }, + "chatPanel.empty.needsKey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a provider and add an API key to start chatting." + } + } + } + }, + "chatPanel.empty.ready": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Ask %@ anything." + } + } + } + }, + "chatPanel.error.http": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "API error (%1$ld): %2$@" + } + } + } + }, + "chatPanel.error.invalidResponse": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Invalid API response" + } + } + } + }, + "chatPanel.error.missingApiKey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Choose a provider and enter an API key in chat settings." + } + } + } + }, + "chatPanel.hide": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide Chat" + } + } + } + }, + "chatPanel.input.needsKey": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add an API key in settings..." + } + } + } + }, + "chatPanel.input.placeholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Message..." + } + } + } + }, + "chatPanel.model.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Model" + } + } + } + }, + "chatPanel.provider": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Provider" + } + } + } + }, + "chatPanel.settings": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat Settings" + } + } + } + }, + "chatPanel.settings.open": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Open Settings" + } + } + } + }, + "chatPanel.settings.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Chat Settings" + } + } + } + }, + "chatPanel.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "AI Chat" + } + } + } + }, + "menu.view.hideChatPanel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Hide AI Chat Panel" + } + } + } + }, + "menu.view.showChatPanel": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Show AI Chat Panel" + } + } + } + }, + "shortcut.toggleAIChatPanel.label": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Toggle AI Chat Panel" + } + } + } } } } diff --git a/Sources/ChatService.swift b/Sources/ChatService.swift index 42e81b2a8..5b9e00408 100644 --- a/Sources/ChatService.swift +++ b/Sources/ChatService.swift @@ -1,4 +1,5 @@ import Foundation +import Security @MainActor final class ChatService: ObservableObject { @@ -144,6 +145,7 @@ final class ChatService: ObservableObject { } private var streamTask: Task? + private var currentStreamID: UUID = UUID() init() { let rawProvider = UserDefaults.standard.string(forKey: Self.selectedProviderDefaultsKey) ?? Provider.claude.rawValue @@ -164,11 +166,37 @@ final class ChatService: ObservableObject { var hasApiKey: Bool { !apiKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } func apiKey(for provider: Provider) -> String { - UserDefaults.standard.string(forKey: provider.apiKeyDefaultsKey) ?? "" + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: "com.cmux.chat.apikey", + kSecAttrAccount: provider.apiKeyDefaultsKey, + kSecReturnData: true, + kSecMatchLimit: kSecMatchLimitOne, + ] + var result: CFTypeRef? + let status = SecItemCopyMatching(query as CFDictionary, &result) + guard status == errSecSuccess, let data = result as? Data else { return "" } + return String(data: data, encoding: .utf8) ?? "" } func setApiKey(_ key: String, for provider: Provider) { - UserDefaults.standard.set(key, forKey: provider.apiKeyDefaultsKey) + let data = key.data(using: .utf8) ?? Data() + let query: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrService: "com.cmux.chat.apikey", + kSecAttrAccount: provider.apiKeyDefaultsKey, + ] + if key.isEmpty { + SecItemDelete(query as CFDictionary) + } else { + let attributes: [CFString: Any] = [kSecValueData: data] + let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary) + if status == errSecItemNotFound { + var addQuery = query + addQuery[kSecValueData] = data + SecItemAdd(addQuery as CFDictionary, nil) + } + } objectWillChange.send() } @@ -210,6 +238,8 @@ final class ChatService: ObservableObject { let provider = selectedProvider let key = apiKey let model = selectedModel + let streamID = UUID() + currentStreamID = streamID streamTask = Task { var assistantContent = "" @@ -256,7 +286,10 @@ final class ChatService: ObservableObject { } } - isStreaming = false + // Only clear streaming state if this is still the active stream. + if streamID == currentStreamID { + isStreaming = false + } } } @@ -270,6 +303,7 @@ final class ChatService: ObservableObject { let legacyKey = UserDefaults.standard.string(forKey: "anthropicAPIKey") ?? "" guard !legacyKey.isEmpty, apiKey(for: .claude).isEmpty else { return } setApiKey(legacyKey, for: .claude) + UserDefaults.standard.removeObject(forKey: "anthropicAPIKey") } private static func buildRequest(messages: [Message], provider: Provider, apiKey: String, model: String) throws -> URLRequest { @@ -367,11 +401,11 @@ final class ChatService: ObservableObject { var components = URLComponents(string: "https://generativelanguage.googleapis.com/v1beta/models/\(model):streamGenerateContent")! components.queryItems = [ URLQueryItem(name: "alt", value: "sse"), - URLQueryItem(name: "key", value: apiKey) ] var request = URLRequest(url: components.url!) request.httpMethod = "POST" + request.setValue(apiKey, forHTTPHeaderField: "x-goog-api-key") request.setValue("application/json", forHTTPHeaderField: "content-type") let body: [String: Any] = [ diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index cc9cc6b35..633b6d2ae 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -1799,7 +1799,7 @@ struct ContentView: View { @EnvironmentObject var sidebarState: SidebarState @EnvironmentObject var sidebarSelectionState: SidebarSelectionState @EnvironmentObject var cmuxConfigStore: CmuxConfigStore - @StateObject private var chatService = ChatService.shared + @ObservedObject private var chatService = ChatService.shared @AppStorage("chatPanelVisible") private var isChatPanelVisible: Bool = true @State private var sidebarWidth: CGFloat = 200 @State private var hoveredResizerHandles: Set = [] diff --git a/Sources/KeyboardShortcutSettings.swift b/Sources/KeyboardShortcutSettings.swift index 155f2e123..33eca13a8 100644 --- a/Sources/KeyboardShortcutSettings.swift +++ b/Sources/KeyboardShortcutSettings.swift @@ -80,6 +80,7 @@ enum KeyboardShortcutSettings { case toggleBrowserDeveloperTools case showBrowserJavaScriptConsole case toggleReactGrab + case toggleAIChatPanel var id: String { rawValue } @@ -141,6 +142,7 @@ enum KeyboardShortcutSettings { case .toggleBrowserDeveloperTools: return String(localized: "shortcut.toggleBrowserDevTools.label", defaultValue: "Toggle Browser Developer Tools") case .showBrowserJavaScriptConsole: return String(localized: "shortcut.showBrowserJSConsole.label", defaultValue: "Show Browser JavaScript Console") case .toggleReactGrab: return String(localized: "shortcut.toggleReactGrab.label", defaultValue: "Toggle React Grab") + case .toggleAIChatPanel: return String(localized: "shortcut.toggleAIChatPanel.label", defaultValue: "Toggle AI Chat Panel") } } @@ -266,8 +268,9 @@ enum KeyboardShortcutSettings { return StoredShortcut(key: "c", command: true, shift: false, option: true, control: false) case .toggleReactGrab: return StoredShortcut(key: "g", command: true, shift: true, option: false, control: false) - } - } + case .toggleAIChatPanel: + return StoredShortcut(key: "\\", command: true, shift: true, option: false, control: false) + } } func tooltip(_ base: String) -> String { "\(base) (\(displayedShortcutString(for: KeyboardShortcutSettings.shortcut(for: self))))" diff --git a/Sources/cmuxApp.swift b/Sources/cmuxApp.swift index 2a4e8f2ae..0edda272d 100644 --- a/Sources/cmuxApp.swift +++ b/Sources/cmuxApp.swift @@ -149,6 +149,7 @@ struct cmuxApp: App { private var showSidebarDevBuildBanner = DevBuildBannerDebugSettings.defaultShowSidebarBanner @AppStorage(SocketControlSettings.appStorageKey) private var socketControlMode = SocketControlSettings.defaultMode.rawValue @AppStorage(BrowserToolbarAccessorySpacingDebugSettings.key) private var browserToolbarAccessorySpacingRaw = BrowserToolbarAccessorySpacingDebugSettings.defaultSpacing + @AppStorage("chatPanelVisible") private var isChatPanelVisible: Bool = true @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate private var browserToolbarAccessorySpacing: Int { @@ -674,15 +675,16 @@ struct cmuxApp: App { } } - Button( - UserDefaults.standard.bool(forKey: "chatPanelVisible") - ? String(localized: "menu.view.hideChatPanel", defaultValue: "Hide AI Chat Panel") - : String(localized: "menu.view.showChatPanel", defaultValue: "Show AI Chat Panel") - ) { - let current = UserDefaults.standard.object(forKey: "chatPanelVisible") as? Bool ?? true - UserDefaults.standard.set(!current, forKey: "chatPanelVisible") + let chatShortcut = menuShortcut(for: .toggleAIChatPanel) + let chatTitle = isChatPanelVisible + ? String(localized: "menu.view.hideChatPanel", defaultValue: "Hide AI Chat Panel") + : String(localized: "menu.view.showChatPanel", defaultValue: "Show AI Chat Panel") + if let key = chatShortcut.keyEquivalent { + Button(chatTitle) { isChatPanelVisible.toggle() } + .keyboardShortcut(key, modifiers: chatShortcut.eventModifiers) + } else { + Button(chatTitle) { isChatPanelVisible.toggle() } } - .keyboardShortcut("\\", modifiers: [.command, .shift]) Divider()