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%
10.0 KB · 263 lines swift
Raw Blame History
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