// // QuickChatPanel.swift // Zyquo Local // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // import AppKit import Carbon.HIToolbox import SwiftUI /// Spotlight-style floating Quick Chat (⌥Space): one prompt, one streamed /// answer from the currently loaded model; offers one-click load of the /// last-used model when none is loaded. @MainActor final class QuickChatPanelController { static let shared = QuickChatPanelController() private var panel: NSPanel? private var hotKeyRef: EventHotKeyRef? private weak var app: AppModel? func setup(app: AppModel) { self.app = app registerHotKey() } func toggle() { if let panel, panel.isVisible { panel.orderOut(nil) return } show() } private func show() { guard let app else { return } let panel: NSPanel if let existing = self.panel { panel = existing } else { panel = NSPanel( contentRect: NSRect(x: 0, y: 0, width: 560, height: 120), styleMask: [.nonactivatingPanel, .titled, .fullSizeContentView], backing: .buffered, defer: false ) panel.titleVisibility = .hidden panel.titlebarAppearsTransparent = true panel.isMovableByWindowBackground = true panel.level = .floating panel.collectionBehavior = [.canJoinAllSpaces, .transient] panel.isFloatingPanel = true panel.becomesKeyOnlyIfNeeded = false panel.hidesOnDeactivate = false panel.backgroundColor = .clear panel.isOpaque = false let host = NSHostingView( rootView: QuickChatView(close: { [weak panel] in panel?.orderOut(nil) }) .environment(app) ) panel.contentView = host self.panel = panel } if let screen = NSScreen.main { let frame = screen.visibleFrame let size = panel.frame.size panel.setFrameOrigin(NSPoint( x: frame.midX - size.width / 2, y: frame.midY + frame.height * 0.12 )) } panel.makeKeyAndOrderFront(nil) NSApp.activate(ignoringOtherApps: true) } /// Carbon global hotkey: ⌥Space. Works without accessibility permission. private func registerHotKey() { guard hotKeyRef == nil else { return } let hotKeyID = EventHotKeyID(signature: OSType(0x5A51_4348), id: 1) // "ZQCH" var eventType = EventTypeSpec( eventClass: OSType(kEventClassKeyboard), eventKind: UInt32(kEventHotKeyPressed)) InstallEventHandler( GetApplicationEventTarget(), { _, _, _ in Task { @MainActor in QuickChatPanelController.shared.toggle() } return noErr }, 1, &eventType, nil, nil ) RegisterEventHotKey( UInt32(kVK_Space), UInt32(optionKey), hotKeyID, GetApplicationEventTarget(), 0, &hotKeyRef ) } } /// The panel content. struct QuickChatView: View { let close: () -> Void @Environment(AppModel.self) private var app @State private var prompt = "" @State private var response = "" @State private var generating = false @FocusState private var focused: Bool var body: some View { VStack(spacing: 0) { HStack(spacing: ZyquoTheme.Spacing.s) { ZyquoGlyph(size: 18) TextField(fieldHint, text: $prompt) .textFieldStyle(.plain) .font(.system(size: 17)) .focused($focused) .onSubmit(submit) .disabled(app.loadedModelID == nil) if generating { ProgressView().controlSize(.small) } } .padding(ZyquoTheme.Spacing.m) if app.loadedModelID == nil { loadHint } if !response.isEmpty { Rectangle().fill(ZyquoTheme.border).frame(height: ZyquoTheme.hairline) ScrollView { MarkdownText(response) .padding(ZyquoTheme.Spacing.m) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 340) } } .frame(width: 560) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l)) .overlay( RoundedRectangle(cornerRadius: ZyquoTheme.Radius.l) .stroke(ZyquoTheme.border, lineWidth: ZyquoTheme.hairline) ) .onAppear { focused = true } .onExitCommand(perform: close) } private var fieldHint: String { if let repoID = app.loadedModelID { return "Ask \(shortModelName(repoID))…" } return "No model loaded" } @ViewBuilder private var loadHint: some View { if let lastUsed = app.store.models.first { HStack { Text("Load \(lastUsed.name) to use Quick Chat") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) Spacer() Button("Load") { Task { await app.loadModel(repoID: lastUsed.repoID) } } .buttonStyle(.borderedProminent) .tint(ZyquoTheme.accent) .controlSize(.small) } .padding(.horizontal, ZyquoTheme.Spacing.m) .padding(.bottom, ZyquoTheme.Spacing.s) } else { Text("Download a model in the Library first.") .font(ZyquoTheme.caption) .foregroundStyle(ZyquoTheme.textSecondary) .padding(.bottom, ZyquoTheme.Spacing.s) } } private func submit() { let text = prompt.trimmingCharacters(in: .whitespacesAndNewlines) guard !text.isEmpty, !generating, app.loadedModelID != nil else { return } response = "" generating = true Task { do { // Ephemeral session; the main conversation session is // restored on its next message. let temp = Conversation(params: app.settings.defaultParams) try await app.engine.startSession(conversation: temp) var parser = ThinkTagParser() let events = try await app.engine.generate(prompt: text, params: temp.params) for try await event in events { if case .token(let t) = event { let (visible, _, _) = parser.consume(t) if !visible.isEmpty { response += visible } } } } catch { response = "*\(error.localizedDescription)*" } generating = false } } }