spb/zyquo-cloud Public MIT
Native macOS AI chat client for 12 cloud providers — your keys, every cloud model, one beautiful chat.
Swift 97.4%
Shell 1.7%
Makefile 1%
1//2// QuickChatPanel.swift3// Zyquo Cloud4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// Global Quick Chat (⌥Space): floating Spotlight-style panel — 640pt wide,9// radius 14, prominent shadow, single input + model chip; the answer expands10// below; ESC dismisses. Appears on the active screen at 30% height.11//1213import SwiftUI1415/// Manages the floating NSPanel hosting QuickChatView and the global hotkey.16@MainActor17final class QuickChatController {18 private var panel: NSPanel?19 private var hotKeyMonitor: Any?20 private let environment: AppEnvironment2122 init(environment: AppEnvironment) {23 self.environment = environment24 installHotKey()25 }2627 private func installHotKey() {28 // ⌥Space, global. Requires no accessibility permission for local29 // monitor; global monitor works while other apps are frontmost.30 hotKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in31 guard event.keyCode == 49, event.modifierFlags.contains(.option) else { return }32 Task { @MainActor in self?.toggle() }33 }34 NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in35 if event.keyCode == 49, event.modifierFlags.contains(.option) {36 Task { @MainActor in self?.toggle() }37 return nil38 }39 return event40 }41 }4243 func toggle() {44 if let panel, panel.isVisible {45 panel.orderOut(nil)46 return47 }48 show()49 }5051 func show() {52 let panel = self.panel ?? makePanel()53 self.panel = panel54 positionOnActiveScreen(panel)55 panel.makeKeyAndOrderFront(nil)56 NSApp.activate(ignoringOtherApps: true)57 }5859 private func makePanel() -> NSPanel {60 let hosting = NSHostingView(61 rootView: QuickChatView(onDismiss: { [weak self] in self?.panel?.orderOut(nil) })62 .environmentObject(environment.catalog)63 .environmentObject(environment.vault)64 .environmentObject(environment.appearance)65 .environmentObject(environment.conversations)66 )67 let panel = KeyablePanel(68 contentRect: NSRect(x: 0, y: 0, width: ZyquoMetrics.quickChatWidth, height: 120),69 styleMask: [.nonactivatingPanel, .fullSizeContentView, .titled],70 backing: .buffered,71 defer: false72 )73 panel.titleVisibility = .hidden74 panel.titlebarAppearsTransparent = true75 panel.isMovableByWindowBackground = true76 panel.level = .floating77 panel.collectionBehavior = [.canJoinAllSpaces, .transient]78 panel.isOpaque = false79 panel.backgroundColor = .clear80 panel.hidesOnDeactivate = false81 panel.contentView = hosting82 return panel83 }8485 private func positionOnActiveScreen(_ panel: NSPanel) {86 let screen = NSScreen.main ?? NSScreen.screens[0]87 let frame = screen.visibleFrame88 let size = panel.frame.size89 let x = frame.midX - size.width / 290 let y = frame.maxY - frame.height * 0.30 - size.height91 panel.setFrameOrigin(NSPoint(x: x, y: y))92 }93}9495/// NSPanel subclass that can become key despite .nonactivatingPanel.96final class KeyablePanel: NSPanel {97 override var canBecomeKey: Bool { true }98 override func cancelOperation(_ sender: Any?) {99 orderOut(nil)100 }101}102103struct QuickChatView: View {104 var onDismiss: () -> Void105106 @EnvironmentObject private var catalog: ModelCatalog107 @EnvironmentObject private var vault: KeyVaultStore108 @EnvironmentObject private var appearance: AppearanceStore109 @EnvironmentObject private var conversations: ConversationStore110111 @State private var input = ""112 @State private var answer = ""113 @State private var reasoning = ""114 @State private var isStreaming = false115 @State private var errorText: String?116 @State private var model: AIModel?117 @State private var streamTask: Task<Void, Never>?118 @FocusState private var focused: Bool119120 var body: some View {121 VStack(spacing: 0) {122 HStack(spacing: ZyquoSpacing.sm) {123 CloudZGlyph(size: 24)124 TextField("Ask anything…", text: $input)125 .textFieldStyle(.plain)126 .font(ZyquoFont.body(size: 16))127 .focused($focused)128 .onSubmit(ask)129 ModelChipView(model: model ?? catalog.defaultModel) { chosen in130 model = chosen131 }132 if isStreaming {133 Button {134 streamTask?.cancel()135 } label: {136 Image(systemName: "stop.fill")137 .foregroundStyle(ZyquoColor.danger)138 }139 .buttonStyle(.plain)140 }141 }142 .padding(ZyquoSpacing.md)143144 if !answer.isEmpty || isStreaming || errorText != nil {145 ZyquoHairline()146 ScrollView {147 VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {148 if let errorText {149 Text(errorText)150 .font(ZyquoFont.body())151 .foregroundStyle(ZyquoColor.danger)152 } else {153 MarkdownView(text: answer, fontSize: appearance.chatFontSize)154 if isStreaming { StreamingCaret() }155 }156 }157 .frame(maxWidth: .infinity, alignment: .leading)158 .padding(ZyquoSpacing.md)159 }160 .frame(maxHeight: 360)161 HStack {162 Spacer()163 Button("Continue in Zyquo Cloud") { continueInApp() }164 .controlSize(.small)165 .disabled(answer.isEmpty)166 }167 .padding(.horizontal, ZyquoSpacing.md)168 .padding(.bottom, ZyquoSpacing.xs)169 }170 }171 .frame(width: ZyquoMetrics.quickChatWidth)172 .background(173 RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)174 .fill(ZyquoColor.surface)175 )176 .overlay(177 RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)178 .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)179 )180 .zyquoSoftShadow()181 .onAppear { focused = true }182 .onExitCommand { onDismiss() }183 }184185 private func ask() {186 let question = input.trimmingCharacters(in: .whitespacesAndNewlines)187 guard !question.isEmpty, !isStreaming else { return }188 guard let target = model ?? catalog.defaultModel else { return }189 answer = ""190 reasoning = ""191 errorText = nil192 isStreaming = true193 streamTask = Task {194 do {195 let key = try vault.apiKey(for: target.provider)196 let client = ProviderRegistry.client(for: target)197 let request = ChatRequest(198 model: target,199 systemPrompt: "Answer concisely.",200 messages: [Message(role: .user, text: question)],201 parameters: ChatParameters()202 )203 for try await event in client.streamChat(request, apiKey: key) {204 if Task.isCancelled { break }205 switch event {206 case .textDelta(let delta): answer += delta207 case .reasoningDelta(let delta): reasoning += delta208 default: break209 }210 }211 } catch {212 errorText = error.localizedDescription213 }214 isStreaming = false215 }216 }217218 /// Moves the exchange into a full conversation in the main window.219 private func continueInApp() {220 let conversation = conversations.newConversation(model: model ?? catalog.defaultModel)221 guard var updated = conversations.conversations.first(where: { $0.id == conversation.id }) else { return }222 var question = Message(role: .user, text: input)223 question.provider = updated.provider224 question.modelID = updated.modelID225 var reply = Message(role: .assistant, text: answer, reasoning: reasoning.isEmpty ? nil : reasoning)226 reply.provider = updated.provider227 reply.modelID = updated.modelID228 updated.messages = [question, reply]229 conversations.update(updated)230 onDismiss()231 NSApp.activate(ignoringOtherApps: true)232 }233}234