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%
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