// // QuickTaskPanel.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Global Quick Task (⌥Space): floating Spotlight-style panel — 640pt wide, // radius 14, soft shadow — that fires a one-off agent task with the default // model in Guarded mode, expands to show the live steps compactly (reusing // StepCardView, including inline approval cards), and can promote the task // to the full window. ESC dismisses. Ported from Zyquo Cloud's QuickChat // controller pattern (NSPanel + ⌥Space global/local key monitors). // import AppKit import SwiftUI /// Manages the floating NSPanel hosting QuickTaskView and the global hotkey. @MainActor final class QuickTaskController { private var panel: NSPanel? private var hotKeyMonitor: Any? private let environment: AppEnvironment init(environment: AppEnvironment) { self.environment = environment installHotKey() } private func installHotKey() { // ⌥Space, global. The global monitor fires while other apps are // frontmost; the local monitor covers Zyquo Agent itself. 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: QuickTaskView(onDismiss: { [weak self] in self?.panel?.orderOut(nil) }) .environmentObject(environment.tasks) .environmentObject(environment.hub) .environmentObject(environment.catalog) .environmentObject(environment.vault) .environmentObject(environment.appearance) .environmentObject(environment.settings) ) let panel = KeyableTaskPanel( contentRect: NSRect(x: 0, y: 0, width: ZyquoMetrics.quickTaskWidth, 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 KeyableTaskPanel: NSPanel { override var canBecomeKey: Bool { true } override func cancelOperation(_ sender: Any?) { orderOut(nil) } } // MARK: - View struct QuickTaskView: View { var onDismiss: () -> Void @EnvironmentObject private var tasks: TaskStore @EnvironmentObject private var hub: RunHub @EnvironmentObject private var catalog: ModelCatalog @EnvironmentObject private var vault: KeyVaultStore @EnvironmentObject private var appearance: AppearanceStore @EnvironmentObject private var settings: AgentSettingsStore @State private var input = "" @State private var model: AIModel? @State private var controller: RunController? @State private var errorText: String? @FocusState private var focused: Bool var body: some View { VStack(spacing: 0) { inputRow if let controller { ZyquoHairline() QuickTaskRunView(controller: controller, fontSize: 12.5) footer(controller) } else if let errorText { ZyquoHairline() Text(errorText) .font(ZyquoFont.body(size: 12.5)) .foregroundStyle(ZyquoColor.danger) .frame(maxWidth: .infinity, alignment: .leading) .padding(ZyquoSpacing.md) } } .frame(width: ZyquoMetrics.quickTaskWidth) .background( RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) .fill(ZyquoColor.surface) ) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous) .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) ) .zyquoSoftShadow() .tint(appearance.accentColor) .onAppear { focused = true } .onExitCommand { onDismiss() } } private var inputRow: some View { HStack(spacing: ZyquoSpacing.sm) { AgentZGlyph(size: 24) TextField("What should I do on your Mac?", text: $input) .textFieldStyle(.plain) .font(ZyquoFont.body(size: 16)) .focused($focused) .onSubmit(run) ZyquoBadge(text: SafetyMode.guarded.displayName, color: ZyquoColor.textSecondary) .help("Quick tasks run in Guarded mode: safe actions auto-run, anything mutating asks") ModelChipView(model: model ?? settings.defaultAgentModel(in: catalog)) { chosen in model = chosen } if controller?.isRunning == true { Button { controller?.cancel() } label: { Image(systemName: "stop.fill") .foregroundStyle(ZyquoColor.danger) } .buttonStyle(.plain) .help("Stop the run") } } .padding(ZyquoSpacing.md) } private func footer(_ controller: RunController) -> some View { HStack { if controller.isRunning { HStack(spacing: ZyquoSpacing.xxs) { ProgressView().controlSize(.mini) Text("Running…") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) } } Spacer() Button("Open in Zyquo Agent") { promote(controller) } .controlSize(.small) } .padding(.horizontal, ZyquoSpacing.md) .padding(.bottom, ZyquoSpacing.xs) } // MARK: - Actions private func run() { let prompt = input.trimmingCharacters(in: .whitespacesAndNewlines) guard !prompt.isEmpty, controller?.isRunning != true else { return } guard let target = model ?? settings.defaultAgentModel(in: catalog) else { errorText = "No agent-capable model available — configure one in Settings › Models." return } guard AgentCLI.resolveAPIKey(for: target.provider) != nil else { errorText = ProviderError.missingAPIKey(target.provider).localizedDescription return } errorText = nil let task = tasks.newTask(model: target, safetyMode: .guarded) let runController = hub.controller(for: task.id) controller = runController runController.start(prompt: prompt, model: target) } /// Brings the task into the full command-center window. private func promote(_ controller: RunController) { tasks.selectedID = controller.taskID onDismiss() NSApp.activate(ignoringOtherApps: true) } } /// The compact live run feed: step cards + inline approval + outcome. private struct QuickTaskRunView: View { @ObservedObject var controller: RunController let fontSize: Double private static let bottomID = "quicktask-bottom" var body: some View { ScrollViewReader { proxy in ScrollView { LazyVStack(alignment: .leading, spacing: ZyquoSpacing.xs) { ForEach(controller.entries) { entry in switch entry { case .step(let step): StepCardView(step: step, fontSize: fontSize, isLive: controller.isRunning) case .compaction(let record): CompactionNoticeView(record: record) } } if let approval = controller.pendingApproval { ApprovalCardView(approval: approval) { resolution in controller.resolveApproval(resolution) } } if let trip = controller.guardTrip { GuardTripCardView( trip: trip, onContinue: { controller.resumeAfterTrip(raisingBudget: true) }, onStop: { controller.stopAfterTrip() } ) } outcomeView if let error = controller.lastError { RunNoticeView(symbol: "exclamationmark.triangle.fill", text: error, color: ZyquoColor.danger) } Color.clear.frame(height: 1).id(Self.bottomID) } .padding(ZyquoSpacing.md) } .frame(maxHeight: 380) .onChange(of: fingerprint) { _ in proxy.scrollTo(Self.bottomID, anchor: .bottom) } } } @ViewBuilder private var outcomeView: some View { switch controller.outcome { case .completed(let answer): MarkdownView(text: answer, fontSize: fontSize) case .failed(let reason): RunNoticeView(symbol: "xmark.circle.fill", text: reason, color: ZyquoColor.danger) case .cancelled: RunNoticeView(symbol: "slash.circle", text: "Run cancelled.", color: ZyquoColor.textTertiary) case .stoppedByUser(let reason): RunNoticeView(symbol: "stop.circle", text: "Run stopped — \(reason)", color: ZyquoColor.textTertiary) case nil: EmptyView() } } private var fingerprint: Int { var value = controller.entries.count &* 13 if case .step(let step)? = controller.entries.last { value &+= step.text.count &+ step.invocations.count } if controller.outcome != nil { value &+= 1 } return value } }