SPB Git

spb/zyquo-agent Public MIT

The autonomous agent that actually operates your Mac — plans, runs real commands, verifies its own work.

Swift 94.7% Shell 4.1% Python 0.7% Makefile 0.5%
11.1 KB · 302 lines swift
Raw Blame History
1//2//  QuickTaskPanel.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Global Quick Task (⌥Space): floating Spotlight-style panel — 640pt wide,9//  radius 14, soft shadow — that fires a one-off agent task with the default10//  model in Guarded mode, expands to show the live steps compactly (reusing11//  StepCardView, including inline approval cards), and can promote the task12//  to the full window. ESC dismisses. Ported from Zyquo Cloud's QuickChat13//  controller pattern (NSPanel + ⌥Space global/local key monitors).14//1516import AppKit17import SwiftUI1819/// Manages the floating NSPanel hosting QuickTaskView and the global hotkey.20@MainActor21final class QuickTaskController {22    private var panel: NSPanel?23    private var hotKeyMonitor: Any?24    private let environment: AppEnvironment2526    init(environment: AppEnvironment) {27        self.environment = environment28        installHotKey()29    }3031    private func installHotKey() {32        // ⌥Space, global. The global monitor fires while other apps are33        // frontmost; the local monitor covers Zyquo Agent itself.34        hotKeyMonitor = NSEvent.addGlobalMonitorForEvents(matching: .keyDown) { [weak self] event in35            guard event.keyCode == 49, event.modifierFlags.contains(.option) else { return }36            Task { @MainActor in self?.toggle() }37        }38        NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event in39            if event.keyCode == 49, event.modifierFlags.contains(.option) {40                Task { @MainActor in self?.toggle() }41                return nil42            }43            return event44        }45    }4647    func toggle() {48        if let panel, panel.isVisible {49            panel.orderOut(nil)50            return51        }52        show()53    }5455    func show() {56        let panel = self.panel ?? makePanel()57        self.panel = panel58        positionOnActiveScreen(panel)59        panel.makeKeyAndOrderFront(nil)60        NSApp.activate(ignoringOtherApps: true)61    }6263    private func makePanel() -> NSPanel {64        let hosting = NSHostingView(65            rootView: QuickTaskView(onDismiss: { [weak self] in self?.panel?.orderOut(nil) })66                .environmentObject(environment.tasks)67                .environmentObject(environment.hub)68                .environmentObject(environment.catalog)69                .environmentObject(environment.vault)70                .environmentObject(environment.appearance)71                .environmentObject(environment.settings)72        )73        let panel = KeyableTaskPanel(74            contentRect: NSRect(x: 0, y: 0, width: ZyquoMetrics.quickTaskWidth, height: 120),75            styleMask: [.nonactivatingPanel, .fullSizeContentView, .titled],76            backing: .buffered,77            defer: false78        )79        panel.titleVisibility = .hidden80        panel.titlebarAppearsTransparent = true81        panel.isMovableByWindowBackground = true82        panel.level = .floating83        panel.collectionBehavior = [.canJoinAllSpaces, .transient]84        panel.isOpaque = false85        panel.backgroundColor = .clear86        panel.hidesOnDeactivate = false87        panel.contentView = hosting88        return panel89    }9091    private func positionOnActiveScreen(_ panel: NSPanel) {92        let screen = NSScreen.main ?? NSScreen.screens[0]93        let frame = screen.visibleFrame94        let size = panel.frame.size95        let x = frame.midX - size.width / 296        let y = frame.maxY - frame.height * 0.30 - size.height97        panel.setFrameOrigin(NSPoint(x: x, y: y))98    }99}100101/// NSPanel subclass that can become key despite .nonactivatingPanel.102final class KeyableTaskPanel: NSPanel {103    override var canBecomeKey: Bool { true }104    override func cancelOperation(_ sender: Any?) {105        orderOut(nil)106    }107}108109// MARK: - View110111struct QuickTaskView: View {112    var onDismiss: () -> Void113114    @EnvironmentObject private var tasks: TaskStore115    @EnvironmentObject private var hub: RunHub116    @EnvironmentObject private var catalog: ModelCatalog117    @EnvironmentObject private var vault: KeyVaultStore118    @EnvironmentObject private var appearance: AppearanceStore119    @EnvironmentObject private var settings: AgentSettingsStore120121    @State private var input = ""122    @State private var model: AIModel?123    @State private var controller: RunController?124    @State private var errorText: String?125    @FocusState private var focused: Bool126127    var body: some View {128        VStack(spacing: 0) {129            inputRow130            if let controller {131                ZyquoHairline()132                QuickTaskRunView(controller: controller, fontSize: 12.5)133                footer(controller)134            } else if let errorText {135                ZyquoHairline()136                Text(errorText)137                    .font(ZyquoFont.body(size: 12.5))138                    .foregroundStyle(ZyquoColor.danger)139                    .frame(maxWidth: .infinity, alignment: .leading)140                    .padding(ZyquoSpacing.md)141            }142        }143        .frame(width: ZyquoMetrics.quickTaskWidth)144        .background(145            RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)146                .fill(ZyquoColor.surface)147        )148        .overlay(149            RoundedRectangle(cornerRadius: ZyquoRadius.large, style: .continuous)150                .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)151        )152        .zyquoSoftShadow()153        .tint(appearance.accentColor)154        .onAppear { focused = true }155        .onExitCommand { onDismiss() }156    }157158    private var inputRow: some View {159        HStack(spacing: ZyquoSpacing.sm) {160            AgentZGlyph(size: 24)161            TextField("What should I do on your Mac?", text: $input)162                .textFieldStyle(.plain)163                .font(ZyquoFont.body(size: 16))164                .focused($focused)165                .onSubmit(run)166            ZyquoBadge(text: SafetyMode.guarded.displayName, color: ZyquoColor.textSecondary)167                .help("Quick tasks run in Guarded mode: safe actions auto-run, anything mutating asks")168            ModelChipView(model: model ?? settings.defaultAgentModel(in: catalog)) { chosen in169                model = chosen170            }171            if controller?.isRunning == true {172                Button {173                    controller?.cancel()174                } label: {175                    Image(systemName: "stop.fill")176                        .foregroundStyle(ZyquoColor.danger)177                }178                .buttonStyle(.plain)179                .help("Stop the run")180            }181        }182        .padding(ZyquoSpacing.md)183    }184185    private func footer(_ controller: RunController) -> some View {186        HStack {187            if controller.isRunning {188                HStack(spacing: ZyquoSpacing.xxs) {189                    ProgressView().controlSize(.mini)190                    Text("Running…")191                        .font(ZyquoFont.caption)192                        .foregroundStyle(ZyquoColor.textSecondary)193                }194            }195            Spacer()196            Button("Open in Zyquo Agent") { promote(controller) }197                .controlSize(.small)198        }199        .padding(.horizontal, ZyquoSpacing.md)200        .padding(.bottom, ZyquoSpacing.xs)201    }202203    // MARK: - Actions204205    private func run() {206        let prompt = input.trimmingCharacters(in: .whitespacesAndNewlines)207        guard !prompt.isEmpty, controller?.isRunning != true else { return }208        guard let target = model ?? settings.defaultAgentModel(in: catalog) else {209            errorText = "No agent-capable model available — configure one in Settings › Models."210            return211        }212        guard AgentCLI.resolveAPIKey(for: target.provider) != nil else {213            errorText = ProviderError.missingAPIKey(target.provider).localizedDescription214            return215        }216        errorText = nil217        let task = tasks.newTask(model: target, safetyMode: .guarded)218        let runController = hub.controller(for: task.id)219        controller = runController220        runController.start(prompt: prompt, model: target)221    }222223    /// Brings the task into the full command-center window.224    private func promote(_ controller: RunController) {225        tasks.selectedID = controller.taskID226        onDismiss()227        NSApp.activate(ignoringOtherApps: true)228    }229}230231/// The compact live run feed: step cards + inline approval + outcome.232private struct QuickTaskRunView: View {233    @ObservedObject var controller: RunController234    let fontSize: Double235236    private static let bottomID = "quicktask-bottom"237238    var body: some View {239        ScrollViewReader { proxy in240            ScrollView {241                LazyVStack(alignment: .leading, spacing: ZyquoSpacing.xs) {242                    ForEach(controller.entries) { entry in243                        switch entry {244                        case .step(let step):245                            StepCardView(step: step, fontSize: fontSize, isLive: controller.isRunning)246                        case .compaction(let record):247                            CompactionNoticeView(record: record)248                        }249                    }250                    if let approval = controller.pendingApproval {251                        ApprovalCardView(approval: approval) { resolution in252                            controller.resolveApproval(resolution)253                        }254                    }255                    if let trip = controller.guardTrip {256                        GuardTripCardView(257                            trip: trip,258                            onContinue: { controller.resumeAfterTrip(raisingBudget: true) },259                            onStop: { controller.stopAfterTrip() }260                        )261                    }262                    outcomeView263                    if let error = controller.lastError {264                        RunNoticeView(symbol: "exclamationmark.triangle.fill", text: error, color: ZyquoColor.danger)265                    }266                    Color.clear.frame(height: 1).id(Self.bottomID)267                }268                .padding(ZyquoSpacing.md)269            }270            .frame(maxHeight: 380)271            .onChange(of: fingerprint) { _ in272                proxy.scrollTo(Self.bottomID, anchor: .bottom)273            }274        }275    }276277    @ViewBuilder278    private var outcomeView: some View {279        switch controller.outcome {280        case .completed(let answer):281            MarkdownView(text: answer, fontSize: fontSize)282        case .failed(let reason):283            RunNoticeView(symbol: "xmark.circle.fill", text: reason, color: ZyquoColor.danger)284        case .cancelled:285            RunNoticeView(symbol: "slash.circle", text: "Run cancelled.", color: ZyquoColor.textTertiary)286        case .stoppedByUser(let reason):287            RunNoticeView(symbol: "stop.circle", text: "Run stopped — \(reason)", color: ZyquoColor.textTertiary)288        case nil:289            EmptyView()290        }291    }292293    private var fingerprint: Int {294        var value = controller.entries.count &* 13295        if case .step(let step)? = controller.entries.last {296            value &+= step.text.count &+ step.invocations.count297        }298        if controller.outcome != nil { value &+= 1 }299        return value300    }301}302