// // AgentSettingsTab.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // Settings › Agent — the engine tunables persisted to AgentSettings.json // and applied to every new run (max steps, token/time budgets, per-command // timeout, parallel tool calls, compaction threshold), plus the Personas // CRUD (system-prompt addition + preferred model + safety default). // import SwiftUI struct AgentSettingsTab: View { @EnvironmentObject private var settings: AgentSettingsStore @EnvironmentObject private var personas: PersonaStore @EnvironmentObject private var catalog: ModelCatalog @State private var editingPersona: Persona? @State private var creatingPersona = false var body: some View { Form { Section("Run budgets (LoopGuard)") { Stepper(value: $settings.settings.maxSteps, in: 5...200, step: 5) { LabeledContent("Max steps per run", value: "\(settings.settings.maxSteps)") } Stepper(value: $settings.settings.tokenBudget, in: 50_000...5_000_000, step: 50_000) { LabeledContent("Token budget per run", value: "\(settings.settings.tokenBudget / 1_000)K") } Stepper(value: $settings.settings.timeBudgetMinutes, in: 5...240, step: 5) { LabeledContent("Time budget per run", value: "\(settings.settings.timeBudgetMinutes) min") } Text("When a budget trips, the run pauses and asks — it never aborts silently.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } Section("Execution") { Stepper(value: $settings.settings.perCommandTimeoutSeconds, in: 10...3600, step: 10) { LabeledContent("Per-command timeout", value: "\(settings.settings.perCommandTimeoutSeconds) s") } Toggle("Parallel tool calls", isOn: $settings.settings.parallelToolCalls) 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.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } Section("Memory") { VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { LabeledContent("Compaction threshold") { Text("\(Int(settings.settings.compactionThreshold * 100))% of context window") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) .monospacedDigit() } Slider(value: $settings.settings.compactionThreshold, in: 0.5...0.95, step: 0.05) } Text("Older completed steps are summarized when estimated context usage crosses this fraction; the plan, MEMORY.md, and recent steps always survive verbatim.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } Section("Personas") { if personas.personas.isEmpty { Text("Personas add a system-prompt section to every run of a task, and can pin a preferred model and safety default.") .font(ZyquoFont.body(size: 12)) .foregroundStyle(ZyquoColor.textTertiary) } ForEach(personas.personas) { persona in personaRow(persona) } Button { creatingPersona = true } label: { Label("New Persona", systemImage: "plus") } .controlSize(.small) } } .formStyle(.grouped) .sheet(item: $editingPersona) { persona in PersonaEditorSheet(persona: persona) { updated in personas.update(updated) } } .sheet(isPresented: $creatingPersona) { PersonaEditorSheet(persona: Persona(name: "", systemPrompt: "")) { created in personas.add(created) } } } private func personaRow(_ persona: Persona) -> some View { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: persona.symbolName) .font(.system(size: 12)) .foregroundStyle(ZyquoColor.accent) .frame(width: 18) VStack(alignment: .leading, spacing: 1) { Text(persona.name) .font(ZyquoFont.bodyEmphasis(size: 12.5)) .foregroundStyle(ZyquoColor.textPrimary) Text(persona.systemPrompt) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) .lineLimit(1) } Spacer() if let modelID = persona.modelID { ZyquoBadge(text: modelID.split(separator: "/").last.map(String.init) ?? modelID) } if let mode = persona.safetyMode { ZyquoBadge(text: mode.displayName, color: ZyquoColor.textSecondary) } Button { editingPersona = persona } label: { Image(systemName: "pencil") .font(.system(size: 11)) .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) .help("Edit persona") Button { personas.delete(persona.id) } label: { Image(systemName: "trash") .font(.system(size: 11)) .foregroundStyle(ZyquoColor.danger) } .buttonStyle(.plain) .help("Delete persona") } } } // MARK: - Persona editor struct PersonaEditorSheet: View { @State var persona: Persona var onSave: (Persona) -> Void @EnvironmentObject private var catalog: ModelCatalog @Environment(\.dismiss) private var dismiss /// Sentinel tag for "no preferred model" in the picker. private static let noModelTag = "" var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { Text(persona.name.isEmpty ? "New Persona" : "Edit Persona") .font(ZyquoFont.title) .foregroundStyle(ZyquoColor.textPrimary) Form { TextField("Name", text: $persona.name) VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { Text("System-prompt addition") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) TextEditor(text: $persona.systemPrompt) .font(ZyquoFont.body(size: 12.5)) .frame(height: 110) .padding(ZyquoSpacing.xxs) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) ) } Picker("Preferred agent model", selection: modelSelection) { Text("None (use default)").tag(Self.noModelTag) ForEach(catalog.agentCapableModels) { model in Text("\(model.displayName) — \(model.provider.displayName)") .tag("\(model.provider.rawValue)|\(model.id)") } } Picker("Default safety mode", selection: safetySelection) { Text("App default").tag(Self.noModelTag) ForEach(SafetyMode.allCases) { mode in Text(mode.displayName).tag(mode.rawValue) } } } HStack { Spacer() Button("Cancel") { dismiss() } Button("Save") { onSave(persona) dismiss() } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) .disabled(persona.name.trimmingCharacters(in: .whitespaces).isEmpty) } } .padding(ZyquoSpacing.xl) .frame(width: 460) .background(ZyquoColor.surface) } private var modelSelection: Binding { Binding( get: { guard let provider = persona.provider, let id = persona.modelID else { return Self.noModelTag } return "\(provider.rawValue)|\(id)" }, set: { key in let parts = key.split(separator: "|", maxSplits: 1) if parts.count == 2, let provider = ProviderID(rawValue: String(parts[0])) { persona.provider = provider persona.modelID = String(parts[1]) } else { persona.provider = nil persona.modelID = nil } } ) } private var safetySelection: Binding { Binding( get: { persona.safetyMode?.rawValue ?? Self.noModelTag }, set: { persona.safetyMode = SafetyMode(rawValue: $0) } ) } }