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// ApprovalCardView.swift3// Zyquo Agent4//5// Author: Simon-Pierre Boucher6// Mail: contact@spboucher.ai7//8// The inline approval card: kind, the EXACT payload in monospace, risk level9// + reason, cwd, model explanation, and the resolution buttons — Approve /10// Approve & remember (safe shell classes only) / Edit (inline editor) /11// Deny. The border pulses gently to draw attention; the run is parked on12// the PolicyEngine's continuation until a button resolves it.13//1415import SwiftUI1617struct ApprovalCardView: View {18 let approval: PendingApproval19 var onResolve: (ApprovalResolution) -> Void2021 @State private var editing = false22 @State private var editedPayload = ""23 @State private var pulsing = false2425 var body: some View {26 VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {27 header28 payloadBlock29 details30 if editing {31 editor32 }33 buttons34 }35 .padding(ZyquoSpacing.md)36 .frame(maxWidth: .infinity, alignment: .leading)37 .background(38 RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)39 .fill(ZyquoColor.warning.opacity(0.06))40 .overlay(41 RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)42 .strokeBorder(ZyquoColor.warning.opacity(pulsing ? 0.9 : 0.4), lineWidth: 1)43 )44 )45 .onAppear {46 withAnimation(ZyquoMotion.pulse) { pulsing = true }47 }48 .transition(.opacity.combined(with: .offset(y: ZyquoMotion.appearRise)))49 }5051 // MARK: Pieces5253 private var header: some View {54 HStack(spacing: ZyquoSpacing.xs) {55 Image(systemName: "hand.raised.fill")56 .font(.system(size: 12))57 .foregroundStyle(ZyquoColor.warning)58 Text("Approval required — \(kindLabel)")59 .font(ZyquoFont.bodyEmphasis())60 .foregroundStyle(ZyquoColor.textPrimary)61 Spacer(minLength: 0)62 ZyquoBadge(text: approval.risk.level.rawValue, color: riskColor)63 }64 }6566 private var payloadBlock: some View {67 ScrollView(.horizontal, showsIndicators: false) {68 Text(approval.action.payload)69 .font(ZyquoFont.code())70 .foregroundStyle(ZyquoColor.textPrimary)71 .textSelection(.enabled)72 .padding(ZyquoSpacing.sm)73 }74 .background(75 RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)76 .fill(ZyquoColor.surfaceSecondary)77 )78 }7980 private var details: some View {81 VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {82 detailRow(label: "Risk", value: approval.risk.reason, color: riskColor)83 detailRow(label: "Directory", value: approval.action.cwd.path, color: ZyquoColor.textSecondary)84 if let explanation = approval.action.explanation, !explanation.isEmpty {85 detailRow(label: "Why", value: explanation, color: ZyquoColor.textSecondary)86 }87 }88 }8990 private func detailRow(label: String, value: String, color: Color) -> some View {91 HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xs) {92 Text(label)93 .font(ZyquoFont.caption)94 .foregroundStyle(ZyquoColor.textTertiary)95 .frame(width: 60, alignment: .trailing)96 Text(value)97 .font(ZyquoFont.body(size: 12.5))98 .foregroundStyle(color)99 .textSelection(.enabled)100 .fixedSize(horizontal: false, vertical: true)101 }102 }103104 private var editor: some View {105 VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {106 Text("Edit before approving — the edited command is re-checked by the safety policy.")107 .font(ZyquoFont.caption)108 .foregroundStyle(ZyquoColor.textSecondary)109 TextEditor(text: $editedPayload)110 .font(ZyquoFont.code())111 .scrollContentBackground(.hidden)112 .frame(minHeight: 48, maxHeight: 140)113 .padding(ZyquoSpacing.xxs)114 .background(115 RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)116 .fill(ZyquoColor.surfaceSecondary)117 )118 }119 }120121 private var buttons: some View {122 HStack(spacing: ZyquoSpacing.xs) {123 if editing {124 Button("Approve Edited") {125 let trimmed = editedPayload.trimmingCharacters(in: .whitespacesAndNewlines)126 guard !trimmed.isEmpty else { return }127 onResolve(.approveEdited(trimmed))128 }129 .buttonStyle(.borderedProminent)130 .disabled(editedPayload.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)131 Button("Cancel Edit") {132 withAnimation(ZyquoMotion.appear) { editing = false }133 }134 } else {135 Button("Approve") { onResolve(.approve) }136 .buttonStyle(.borderedProminent)137 .keyboardShortcut(.defaultAction)138 if canRemember {139 Button("Approve & Remember") { onResolve(.approveAndRemember) }140 .help("Also remember a narrow allow rule for this command class")141 }142 Button("Edit") {143 editedPayload = approval.action.payload144 withAnimation(ZyquoMotion.appear) { editing = true }145 }146 }147 Spacer(minLength: 0)148 Button(role: .destructive) {149 onResolve(.deny)150 } label: {151 Text("Deny")152 .foregroundStyle(ZyquoColor.danger)153 }154 .keyboardShortcut(.cancelAction)155 }156 .controlSize(.regular)157 }158159 // MARK: Helpers160161 private var kindLabel: String {162 switch approval.action.kind {163 case .shellCommand: return "shell command"164 case .appleScript: return "AppleScript"165 case .fileWrite: return "file write (workspace)"166 case .fileWriteOutsideWorkspace: return "file write OUTSIDE the workspace"167 case .fileReadOutsideWorkspace: return "file read OUTSIDE the workspace"168 }169 }170171 /// "Approve & remember" persists per-subcommand allow rules — only shell172 /// commands have a narrow, stable pattern to remember, and destructive/173 /// elevated classes can never be remembered away.174 private var canRemember: Bool {175 approval.action.kind == .shellCommand176 && approval.risk.level != .destructive177 && approval.risk.level != .elevated178 }179180 private var riskColor: Color {181 switch approval.risk.level {182 case .safe: return ZyquoColor.success183 case .mutating: return ZyquoColor.warning184 case .destructive, .elevated: return ZyquoColor.danger185 }186 }187}188189// MARK: - LoopGuard trip card190191/// Inline pause card for a LoopGuard trip: reason + Continue (raise budget)192/// or Stop.193struct GuardTripCardView: View {194 let trip: LoopGuardTrip195 var onContinue: () -> Void196 var onStop: () -> Void197198 var body: some View {199 VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {200 HStack(spacing: ZyquoSpacing.xs) {201 Image(systemName: "pause.circle.fill")202 .font(.system(size: 12))203 .foregroundStyle(ZyquoColor.warning)204 Text("Run paused — \(tripTitle)")205 .font(ZyquoFont.bodyEmphasis())206 .foregroundStyle(ZyquoColor.textPrimary)207 Spacer(minLength: 0)208 Text("step \(trip.stepIndex)")209 .font(ZyquoFont.caption)210 .foregroundStyle(ZyquoColor.textTertiary)211 }212 Text(trip.message)213 .font(ZyquoFont.body(size: 12.5))214 .foregroundStyle(ZyquoColor.textSecondary)215 .fixedSize(horizontal: false, vertical: true)216 HStack(spacing: ZyquoSpacing.xs) {217 Button("Continue with raised budget") { onContinue() }218 .buttonStyle(.borderedProminent)219 Button("Stop") { onStop() }220 Spacer(minLength: 0)221 }222 }223 .padding(ZyquoSpacing.md)224 .frame(maxWidth: .infinity, alignment: .leading)225 .background(226 RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)227 .fill(ZyquoColor.warning.opacity(0.06))228 .overlay(229 RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)230 .strokeBorder(ZyquoColor.warning.opacity(0.5), lineWidth: 1)231 )232 )233 .transition(.opacity.combined(with: .offset(y: ZyquoMotion.appearRise)))234 }235236 private var tripTitle: String {237 switch trip.reason {238 case .maxSteps: return "step limit reached"239 case .tokenBudget: return "token budget reached"240 case .wallClockBudget: return "time budget reached"241 case .repetition: return "repeating a failing action"242 case .stall: return "no progress detected"243 }244 }245}246247/// One-line compaction notice in the transcript.248struct CompactionNoticeView: View {249 let record: CompactionRecord250251 var body: some View {252 HStack(spacing: ZyquoSpacing.xs) {253 Image(systemName: "arrow.triangle.2.circlepath")254 .font(.system(size: 10))255 Text("Compacted \(record.summarizedSteps) step\(record.summarizedSteps == 1 ? "" : "s") — ~\(record.beforeTokens) → ~\(record.afterTokens) tokens in context")256 .font(ZyquoFont.caption)257 }258 .foregroundStyle(ZyquoColor.textTertiary)259 .frame(maxWidth: .infinity, alignment: .center)260 .padding(.vertical, ZyquoSpacing.xxs)261 }262}263