// // ApprovalCardView.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The inline approval card: kind, the EXACT payload in monospace, risk level // + reason, cwd, model explanation, and the resolution buttons — Approve / // Approve & remember (safe shell classes only) / Edit (inline editor) / // Deny. The border pulses gently to draw attention; the run is parked on // the PolicyEngine's continuation until a button resolves it. // import SwiftUI struct ApprovalCardView: View { let approval: PendingApproval var onResolve: (ApprovalResolution) -> Void @State private var editing = false @State private var editedPayload = "" @State private var pulsing = false var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { header payloadBlock details if editing { editor } buttons } .padding(ZyquoSpacing.md) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .fill(ZyquoColor.warning.opacity(0.06)) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .strokeBorder(ZyquoColor.warning.opacity(pulsing ? 0.9 : 0.4), lineWidth: 1) ) ) .onAppear { withAnimation(ZyquoMotion.pulse) { pulsing = true } } .transition(.opacity.combined(with: .offset(y: ZyquoMotion.appearRise))) } // MARK: Pieces private var header: some View { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: "hand.raised.fill") .font(.system(size: 12)) .foregroundStyle(ZyquoColor.warning) Text("Approval required — \(kindLabel)") .font(ZyquoFont.bodyEmphasis()) .foregroundStyle(ZyquoColor.textPrimary) Spacer(minLength: 0) ZyquoBadge(text: approval.risk.level.rawValue, color: riskColor) } } private var payloadBlock: some View { ScrollView(.horizontal, showsIndicators: false) { Text(approval.action.payload) .font(ZyquoFont.code()) .foregroundStyle(ZyquoColor.textPrimary) .textSelection(.enabled) .padding(ZyquoSpacing.sm) } .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary) ) } private var details: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { detailRow(label: "Risk", value: approval.risk.reason, color: riskColor) detailRow(label: "Directory", value: approval.action.cwd.path, color: ZyquoColor.textSecondary) if let explanation = approval.action.explanation, !explanation.isEmpty { detailRow(label: "Why", value: explanation, color: ZyquoColor.textSecondary) } } } private func detailRow(label: String, value: String, color: Color) -> some View { HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xs) { Text(label) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) .frame(width: 60, alignment: .trailing) Text(value) .font(ZyquoFont.body(size: 12.5)) .foregroundStyle(color) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } } private var editor: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { Text("Edit before approving — the edited command is re-checked by the safety policy.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) TextEditor(text: $editedPayload) .font(ZyquoFont.code()) .scrollContentBackground(.hidden) .frame(minHeight: 48, maxHeight: 140) .padding(ZyquoSpacing.xxs) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary) ) } } private var buttons: some View { HStack(spacing: ZyquoSpacing.xs) { if editing { Button("Approve Edited") { let trimmed = editedPayload.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } onResolve(.approveEdited(trimmed)) } .buttonStyle(.borderedProminent) .disabled(editedPayload.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) Button("Cancel Edit") { withAnimation(ZyquoMotion.appear) { editing = false } } } else { Button("Approve") { onResolve(.approve) } .buttonStyle(.borderedProminent) .keyboardShortcut(.defaultAction) if canRemember { Button("Approve & Remember") { onResolve(.approveAndRemember) } .help("Also remember a narrow allow rule for this command class") } Button("Edit") { editedPayload = approval.action.payload withAnimation(ZyquoMotion.appear) { editing = true } } } Spacer(minLength: 0) Button(role: .destructive) { onResolve(.deny) } label: { Text("Deny") .foregroundStyle(ZyquoColor.danger) } .keyboardShortcut(.cancelAction) } .controlSize(.regular) } // MARK: Helpers private var kindLabel: String { switch approval.action.kind { case .shellCommand: return "shell command" case .appleScript: return "AppleScript" case .fileWrite: return "file write (workspace)" case .fileWriteOutsideWorkspace: return "file write OUTSIDE the workspace" case .fileReadOutsideWorkspace: return "file read OUTSIDE the workspace" } } /// "Approve & remember" persists per-subcommand allow rules — only shell /// commands have a narrow, stable pattern to remember, and destructive/ /// elevated classes can never be remembered away. private var canRemember: Bool { approval.action.kind == .shellCommand && approval.risk.level != .destructive && approval.risk.level != .elevated } private var riskColor: Color { switch approval.risk.level { case .safe: return ZyquoColor.success case .mutating: return ZyquoColor.warning case .destructive, .elevated: return ZyquoColor.danger } } } // MARK: - LoopGuard trip card /// Inline pause card for a LoopGuard trip: reason + Continue (raise budget) /// or Stop. struct GuardTripCardView: View { let trip: LoopGuardTrip var onContinue: () -> Void var onStop: () -> Void var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: "pause.circle.fill") .font(.system(size: 12)) .foregroundStyle(ZyquoColor.warning) Text("Run paused — \(tripTitle)") .font(ZyquoFont.bodyEmphasis()) .foregroundStyle(ZyquoColor.textPrimary) Spacer(minLength: 0) Text("step \(trip.stepIndex)") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } Text(trip.message) .font(ZyquoFont.body(size: 12.5)) .foregroundStyle(ZyquoColor.textSecondary) .fixedSize(horizontal: false, vertical: true) HStack(spacing: ZyquoSpacing.xs) { Button("Continue with raised budget") { onContinue() } .buttonStyle(.borderedProminent) Button("Stop") { onStop() } Spacer(minLength: 0) } } .padding(ZyquoSpacing.md) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .fill(ZyquoColor.warning.opacity(0.06)) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .strokeBorder(ZyquoColor.warning.opacity(0.5), lineWidth: 1) ) ) .transition(.opacity.combined(with: .offset(y: ZyquoMotion.appearRise))) } private var tripTitle: String { switch trip.reason { case .maxSteps: return "step limit reached" case .tokenBudget: return "token budget reached" case .wallClockBudget: return "time budget reached" case .repetition: return "repeating a failing action" case .stall: return "no progress detected" } } } /// One-line compaction notice in the transcript. struct CompactionNoticeView: View { let record: CompactionRecord var body: some View { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: "arrow.triangle.2.circlepath") .font(.system(size: 10)) Text("Compacted \(record.summarizedSteps) step\(record.summarizedSteps == 1 ? "" : "s") — ~\(record.beforeTokens) → ~\(record.afterTokens) tokens in context") .font(ZyquoFont.caption) } .foregroundStyle(ZyquoColor.textTertiary) .frame(maxWidth: .infinity, alignment: .center) .padding(.vertical, ZyquoSpacing.xxs) } }