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%
7.6 KB · 180 lines swift
Raw Blame History
1//2//  EmptyStateView.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The "What should I do on your Mac?" hero: brand glyph, four example task9//  cards (clicking fills the input), the safety-mode selector, and the model10//  chip — powerful and trustworthy, never blank.11//1213import SwiftUI1415struct AgentEmptyStateView: View {16    let model: AIModel?17    let safetyMode: SafetyMode18    /// Name of the task's active persona (nil = none / no task yet).19    var personaName: String?20    var onSelectModel: (AIModel) -> Void21    var onSelectSafetyMode: (SafetyMode) -> Void22    /// Called with the chosen persona (nil = none).23    var onSelectPersona: ((Persona?) -> Void)?24    var onBrowseTemplates: (() -> Void)?25    var onSuggestion: (String) -> Void2627    @EnvironmentObject private var personas: PersonaStore2829    private static let suggestions: [(symbol: String, title: String, prompt: String)] = [30        ("folder.badge.gearshape", "Organize my Downloads folder",31         "Look at my Downloads folder, group the files by type into subfolders (Images, Documents, Archives, Installers…), and show me a summary of what you moved."),32        ("textformat.abc.dottedunderline", "Batch-rename these files",33         "Rename all the files in the workspace to a consistent kebab-case pattern with a numeric suffix, and list the before → after mapping."),34        ("chevron.left.forwardslash.chevron.right", "Set up a Python project and run the tests",35         "Create a small Python project in the workspace with a src/ layout, one example module with two functions, pytest tests for them, then run the tests and report the results."),36        ("note.text", "Export my Notes to Markdown",37         "Use AppleScript to read my Apple Notes and export each note in the default folder as a Markdown file in the workspace, named after its title."),38    ]3940    var body: some View {41        VStack(spacing: ZyquoSpacing.lg) {42            Spacer()43            AgentZGlyph(size: 88)44            VStack(spacing: ZyquoSpacing.xxs) {45                Text("What should I do on your Mac?")46                    .font(ZyquoFont.title)47                    .foregroundStyle(ZyquoColor.textPrimary)48                Text("Zyquo Agent plans, runs commands, and works until it's done — with your approval on anything risky.")49                    .font(ZyquoFont.body())50                    .foregroundStyle(ZyquoColor.textSecondary)51                    .multilineTextAlignment(.center)52            }53            HStack(spacing: ZyquoSpacing.sm) {54                ModelChipView(model: model, onSelect: onSelectModel)55                SafetyModePicker(mode: safetyMode, onSelect: onSelectSafetyMode)56                if let onSelectPersona, !personas.personas.isEmpty {57                    personaMenu(onSelectPersona)58                }59            }60            if let onBrowseTemplates {61                Button {62                    onBrowseTemplates()63                } label: {64                    HStack(spacing: ZyquoSpacing.xxs) {65                        Image(systemName: "square.grid.2x2")66                            .font(.system(size: 11))67                        Text("Browse templates")68                            .font(ZyquoFont.body(size: 12.5))69                    }70                    .foregroundStyle(ZyquoColor.accent)71                }72                .buttonStyle(.plain)73                .help("25+ ready-made agent tasks (⌘K)")74            }75            LazyVGrid(76                columns: [GridItem(.flexible()), GridItem(.flexible())],77                spacing: ZyquoSpacing.sm78            ) {79                ForEach(Self.suggestions, id: \.title) { suggestion in80                    Button {81                        onSuggestion(suggestion.prompt)82                    } label: {83                        HStack(spacing: ZyquoSpacing.xs) {84                            Image(systemName: suggestion.symbol)85                                .font(.system(size: 14))86                                .foregroundStyle(ZyquoColor.accent)87                                .frame(width: 20)88                            Text(suggestion.title)89                                .font(ZyquoFont.body(size: 13))90                                .foregroundStyle(ZyquoColor.textPrimary)91                                .multilineTextAlignment(.leading)92                            Spacer(minLength: 0)93                        }94                        .padding(ZyquoSpacing.sm)95                        .background(96                            RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)97                                .fill(ZyquoColor.surface)98                                .overlay(99                                    RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)100                                        .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)101                                )102                        )103                        .contentShape(Rectangle())104                    }105                    .buttonStyle(PressableButtonStyle())106                }107            }108            .frame(maxWidth: ZyquoMetrics.emptyStateGridWidth)109            Spacer()110            Spacer()111        }112        .frame(maxWidth: .infinity, maxHeight: .infinity)113        .background(ZyquoColor.background)114    }115116    /// Persona chip + menu (None + every stored persona).117    private func personaMenu(_ onSelect: @escaping (Persona?) -> Void) -> some View {118        Menu {119            Button("None") { onSelect(nil) }120            Divider()121            ForEach(personas.personas) { persona in122                Button {123                    onSelect(persona)124                } label: {125                    Label(persona.name, systemImage: persona.symbolName)126                }127            }128        } label: {129            HStack(spacing: ZyquoSpacing.xxs) {130                Image(systemName: "person.crop.circle")131                    .font(.system(size: 11, weight: .medium))132                    .foregroundStyle(ZyquoColor.accent)133                Text(personaName ?? "Persona")134                    .font(ZyquoFont.bodyEmphasis(size: 12.5))135                    .foregroundStyle(personaName == nil ? ZyquoColor.textSecondary : ZyquoColor.textPrimary)136                    .lineLimit(1)137            }138            .padding(.horizontal, ZyquoSpacing.xs)139            .padding(.vertical, 4)140            .background(141                RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)142                    .fill(ZyquoColor.surfaceSecondary)143            )144        }145        .menuStyle(.borderlessButton)146        .fixedSize()147        .help("Persona — adds a system-prompt section and can pin a model and safety default")148    }149}150151/// The Manual / Guarded / Autonomous segmented control (header + empty state).152struct SafetyModePicker: View {153    let mode: SafetyMode154    var onSelect: (SafetyMode) -> Void155156    var body: some View {157        Picker("Safety mode", selection: Binding(158            get: { mode },159            set: { onSelect($0) }160        )) {161            ForEach(SafetyMode.allCases) { candidate in162                Text(candidate.displayName).tag(candidate)163            }164        }165        .pickerStyle(.segmented)166        .labelsHidden()167        .controlSize(.small)168        .fixedSize()169        .help(helpText)170    }171172    private var helpText: String {173        switch mode {174        case .manual: return "Manual — approve every action"175        case .guarded: return "Guarded — safe actions run automatically, anything mutating asks"176        case .autonomous: return "Autonomous — runs freely within budget; destructive actions still ask"177        }178    }179}180