// // QuickChatPanel.swift // Zyquo Cloud // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Global Quick Chat (⌥Space): floating Spotlight-style panel — 640pt wide, // radius 14, prominent shadow, single input + model chip; the answer expands // below; ESC dismisses. Appears on the active screen at 30% height. // import SwiftUI /// Manages the floating NSPanel hosting QuickChatView and the global hotkey. @MainActor final class QuickChatController { private var panel: NSPanel? private var hotKeyMonitor: Any? private let environment: AppEnvironment init(environment: AppEnvironment) { self.environment = environment installHotKey() } private func installHotKey() { // ⌥Space, global. Requires no accessibility permission for local // monitor; global monitor works while other apps are frontmost. hotKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in guard event.keyCode == 49, event.modifierFlags.contains(.option) else { return } Task { @MainActor in self?.toggle() } } NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in if event.keyCode == 49, event.modifierFlags.contains(.option) { Task { @MainActor in self?.toggle() } return nil } return event } } func toggle() { if let panel, panel.isVisible { panel.orderOut(nil) return } show() } func show() { let panel = self.panel ?? makePanel() self.panel = panel positionOnActiveScreen(panel) panel.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } private func makePanel() -> NSPanel { let hosting = NSHostingView( rootView: QuickChatView(onDismiss: { [weak self] in self?.panel?.orderOut(nil) }) .environmentObject(environment.catalog) .environmentObject(environment.vault) .environmentObject(environment.appearance) .environmentObject(environment.conversations) ) let panel = KeyablePanel( contentRect: NSRect(x: 0, y: 0, width: ZyquoMetrics.quickChatWidth, height: 120), styleMask: [.nonactivatingPanel, .fullSizeContentView, .titled], backing: .buffered, defer: false ) panel.titleVisibility = .hidden panel.titlebarAppearsTransparent = true panel.isMovableByWindowBackground = true panel.level = .floating panel.collectionBehavior = [.canJoinAllSpaces, .transient] panel.isOpaque = false panel.backgroundColor = .clear panel.hidesOnDeactivate = false panel.contentView = hosting return panel } private func positionOnActiveScreen(_ panel: NSPanel) { let screen = NSScreen.main ?? NSScreen.screens[0] let frame = screen.visibleFrame let size = panel.frame.size let x = frame.midX - size.width / 2 let y = frame.maxY - frame.height * 0.30 - size.height panel.setFrameOrigin(NSPoint(x: x, y: y)) } } /// NSPanel subclass that can become key despite .nonactivatingPanel. final class KeyablePanel: NSPanel { override var canBecomeKey: Bool { true } override func cancelOperation(_ sender: Any?) { orderOut(nil) } } struct QuickChatView: View { var onDismiss: () -> Void @EnvironmentObject private var catalog: ModelCatalog @EnvironmentObject private var vault: KeyVaultStore @EnvironmentObject private var appearance: AppearanceStore @EnvironmentObject private var conversations: ConversationStore @State private var input = "" @State private var answer = "" @State private var reasoning = "" @State private var isStreaming = false @State private var errorText: String? @State private var model: AIModel? @State private var streamTask: Task? @FocusState private var focused: Bool var body: some View { VStack(spacing: 0) { HStack(spacing: ZyquoSpacing.sm) { CloudZGlyph(size: 24) TextField("Ask anything…", text: $input) .textFieldStyle(.plain) .font(ZyquoFont.body(size: 16)) .focused($focused) .onSubmit(ask) ModelChipView(model: model ?? catalog.defaultModel) { chosen in model = chosen } if isStreaming { Button { streamTask?.cancel() } label: { Image(systemName: "stop.fill") .foregroundStyle(ZyquoColor.danger) } .buttonStyle(.plain) } } .padding(ZyquoSpacing.md) if !answer.isEmpty || isStreaming || errorText != nil { ZyquoHairline() ScrollView { VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { if let errorText { Text(errorText) .font(ZyquoFont.body()) .foregroundStyle(ZyquoColor.danger) } else { MarkdownView(text: answer, fontSize: appearance.chatFontSize) if isStreaming { StreamingCaret() } } } .frame(maxWidth: .infinity, alignment: .leading) .padding(ZyquoSpacing.md) } .frame(maxHeight: 360) HStack { Spacer() Button("Continue in Zyquo Cloud") { continueInApp() } .controlSize(.small) .disabled(answer.isEmpty) } .padding(.horizontal, ZyquoSpacing.md) .padding(.bottom, ZyquoSpacing.xs) } } .frame(width: ZyquoMetrics.quickChatWidth) .background( RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) .fill(ZyquoColor.surface) ) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) ) .zyquoSoftShadow() .onAppear { focused = true } .onExitCommand { onDismiss() } } private func ask() { let question = input.trimmingCharacters(in: .whitespacesAndNewlines) guard !question.isEmpty, !isStreaming else { return } guard let target = model ?? catalog.defaultModel else { return } answer = "" reasoning = "" errorText = nil isStreaming = true streamTask = Task { do { let key = try vault.apiKey(for: target.provider) let client = ProviderRegistry.client(for: target) let request = ChatRequest( model: target, systemPrompt: "Answer concisely.", messages: [Message(role: .user, text: question)], parameters: ChatParameters() ) for try await event in client.streamChat(request, apiKey: key) { if Task.isCancelled { break } switch event { case .textDelta(let delta): answer += delta case .reasoningDelta(let delta): reasoning += delta default: break } } } catch { errorText = error.localizedDescription } isStreaming = false } } /// Moves the exchange into a full conversation in the main window. private func continueInApp() { let conversation = conversations.newConversation(model: model ?? catalog.defaultModel) guard var updated = conversations.conversations.first(where: { $0.id == conversation.id }) else { return } var question = Message(role: .user, text: input) question.provider = updated.provider question.modelID = updated.modelID var reply = Message(role: .assistant, text: answer, reasoning: reasoning.isEmpty ? nil : reasoning) reply.provider = updated.provider reply.modelID = updated.modelID updated.messages = [question, reply] conversations.update(updated) onDismiss() NSApp.activate(ignoringOtherApps: true) } }