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%
9.4 KB · 228 lines swift
Raw Blame History
1//2//  AgentSettingsTab.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  Settings › Agent — the engine tunables persisted to AgentSettings.json9//  and applied to every new run (max steps, token/time budgets, per-command10//  timeout, parallel tool calls, compaction threshold), plus the Personas11//  CRUD (system-prompt addition + preferred model + safety default).12//1314import SwiftUI1516struct AgentSettingsTab: View {17    @EnvironmentObject private var settings: AgentSettingsStore18    @EnvironmentObject private var personas: PersonaStore19    @EnvironmentObject private var catalog: ModelCatalog20    @State private var editingPersona: Persona?21    @State private var creatingPersona = false2223    var body: some View {24        Form {25            Section("Run budgets (LoopGuard)") {26                Stepper(value: $settings.settings.maxSteps, in: 5...200, step: 5) {27                    LabeledContent("Max steps per run", value: "\(settings.settings.maxSteps)")28                }29                Stepper(value: $settings.settings.tokenBudget, in: 50_000...5_000_000, step: 50_000) {30                    LabeledContent("Token budget per run", value: "\(settings.settings.tokenBudget / 1_000)K")31                }32                Stepper(value: $settings.settings.timeBudgetMinutes, in: 5...240, step: 5) {33                    LabeledContent("Time budget per run", value: "\(settings.settings.timeBudgetMinutes) min")34                }35                Text("When a budget trips, the run pauses and asks — it never aborts silently.")36                    .font(ZyquoFont.caption)37                    .foregroundStyle(ZyquoColor.textTertiary)38            }3940            Section("Execution") {41                Stepper(value: $settings.settings.perCommandTimeoutSeconds, in: 10...3600, step: 10) {42                    LabeledContent("Per-command timeout", value: "\(settings.settings.perCommandTimeoutSeconds) s")43                }44                Toggle("Parallel tool calls", isOn: $settings.settings.parallelToolCalls)45                Text("When on, a turn's tool calls run concurrently — but only when every call is read-only under the active policy. Mutating calls always run sequentially.")46                    .font(ZyquoFont.caption)47                    .foregroundStyle(ZyquoColor.textTertiary)48            }4950            Section("Memory") {51                VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {52                    LabeledContent("Compaction threshold") {53                        Text("\(Int(settings.settings.compactionThreshold * 100))% of context window")54                            .font(ZyquoFont.caption)55                            .foregroundStyle(ZyquoColor.textSecondary)56                            .monospacedDigit()57                    }58                    Slider(value: $settings.settings.compactionThreshold, in: 0.5...0.95, step: 0.05)59                }60                Text("Older completed steps are summarized when estimated context usage crosses this fraction; the plan, MEMORY.md, and recent steps always survive verbatim.")61                    .font(ZyquoFont.caption)62                    .foregroundStyle(ZyquoColor.textTertiary)63            }6465            Section("Personas") {66                if personas.personas.isEmpty {67                    Text("Personas add a system-prompt section to every run of a task, and can pin a preferred model and safety default.")68                        .font(ZyquoFont.body(size: 12))69                        .foregroundStyle(ZyquoColor.textTertiary)70                }71                ForEach(personas.personas) { persona in72                    personaRow(persona)73                }74                Button {75                    creatingPersona = true76                } label: {77                    Label("New Persona", systemImage: "plus")78                }79                .controlSize(.small)80            }81        }82        .formStyle(.grouped)83        .sheet(item: $editingPersona) { persona in84            PersonaEditorSheet(persona: persona) { updated in85                personas.update(updated)86            }87        }88        .sheet(isPresented: $creatingPersona) {89            PersonaEditorSheet(persona: Persona(name: "", systemPrompt: "")) { created in90                personas.add(created)91            }92        }93    }9495    private func personaRow(_ persona: Persona) -> some View {96        HStack(spacing: ZyquoSpacing.xs) {97            Image(systemName: persona.symbolName)98                .font(.system(size: 12))99                .foregroundStyle(ZyquoColor.accent)100                .frame(width: 18)101            VStack(alignment: .leading, spacing: 1) {102                Text(persona.name)103                    .font(ZyquoFont.bodyEmphasis(size: 12.5))104                    .foregroundStyle(ZyquoColor.textPrimary)105                Text(persona.systemPrompt)106                    .font(ZyquoFont.caption)107                    .foregroundStyle(ZyquoColor.textTertiary)108                    .lineLimit(1)109            }110            Spacer()111            if let modelID = persona.modelID {112                ZyquoBadge(text: modelID.split(separator: "/").last.map(String.init) ?? modelID)113            }114            if let mode = persona.safetyMode {115                ZyquoBadge(text: mode.displayName, color: ZyquoColor.textSecondary)116            }117            Button {118                editingPersona = persona119            } label: {120                Image(systemName: "pencil")121                    .font(.system(size: 11))122                    .foregroundStyle(ZyquoColor.textSecondary)123            }124            .buttonStyle(.plain)125            .help("Edit persona")126            Button {127                personas.delete(persona.id)128            } label: {129                Image(systemName: "trash")130                    .font(.system(size: 11))131                    .foregroundStyle(ZyquoColor.danger)132            }133            .buttonStyle(.plain)134            .help("Delete persona")135        }136    }137}138139// MARK: - Persona editor140141struct PersonaEditorSheet: View {142    @State var persona: Persona143    var onSave: (Persona) -> Void144145    @EnvironmentObject private var catalog: ModelCatalog146    @Environment(\.dismiss) private var dismiss147148    /// Sentinel tag for "no preferred model" in the picker.149    private static let noModelTag = ""150151    var body: some View {152        VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {153            Text(persona.name.isEmpty ? "New Persona" : "Edit Persona")154                .font(ZyquoFont.title)155                .foregroundStyle(ZyquoColor.textPrimary)156            Form {157                TextField("Name", text: $persona.name)158                VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {159                    Text("System-prompt addition")160                        .font(ZyquoFont.caption)161                        .foregroundStyle(ZyquoColor.textSecondary)162                    TextEditor(text: $persona.systemPrompt)163                        .font(ZyquoFont.body(size: 12.5))164                        .frame(height: 110)165                        .padding(ZyquoSpacing.xxs)166                        .background(167                            RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)168                                .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)169                        )170                }171                Picker("Preferred agent model", selection: modelSelection) {172                    Text("None (use default)").tag(Self.noModelTag)173                    ForEach(catalog.agentCapableModels) { model in174                        Text("\(model.displayName)\(model.provider.displayName)")175                            .tag("\(model.provider.rawValue)|\(model.id)")176                    }177                }178                Picker("Default safety mode", selection: safetySelection) {179                    Text("App default").tag(Self.noModelTag)180                    ForEach(SafetyMode.allCases) { mode in181                        Text(mode.displayName).tag(mode.rawValue)182                    }183                }184            }185            HStack {186                Spacer()187                Button("Cancel") { dismiss() }188                Button("Save") {189                    onSave(persona)190                    dismiss()191                }192                .buttonStyle(.borderedProminent)193                .keyboardShortcut(.defaultAction)194                .disabled(persona.name.trimmingCharacters(in: .whitespaces).isEmpty)195            }196        }197        .padding(ZyquoSpacing.xl)198        .frame(width: 460)199        .background(ZyquoColor.surface)200    }201202    private var modelSelection: Binding<String> {203        Binding(204            get: {205                guard let provider = persona.provider, let id = persona.modelID else { return Self.noModelTag }206                return "\(provider.rawValue)|\(id)"207            },208            set: { key in209                let parts = key.split(separator: "|", maxSplits: 1)210                if parts.count == 2, let provider = ProviderID(rawValue: String(parts[0])) {211                    persona.provider = provider212                    persona.modelID = String(parts[1])213                } else {214                    persona.provider = nil215                    persona.modelID = nil216                }217            }218        )219    }220221    private var safetySelection: Binding<String> {222        Binding(223            get: { persona.safetyMode?.rawValue ?? Self.noModelTag },224            set: { persona.safetyMode = SafetyMode(rawValue: $0) }225        )226    }227}228