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%
8.2 KB · 231 lines swift
Raw Blame History
1//2//  PlanPanelView.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  The right-hand Plan/Todo panel (~300pt, collapsible): the live checklist9//  the agent maintains via `update_plan` — items animate through pending/10//  active/done/failed, titles are user-editable — plus a progress bar and the11//  LoopGuard meters (steps, tokens, elapsed time vs. budgets).12//1314import Combine15import SwiftUI1617struct PlanPanelView: View {18    @ObservedObject var controller: RunController1920    /// Ticks the elapsed-time meter while a run is live.21    @State private var now = Date()22    private let clock = Timer.publish(every: 1, on: .main, in: .common).autoconnect()2324    var body: some View {25        VStack(alignment: .leading, spacing: 0) {26            header27            ZyquoHairline()28            ScrollView {29                VStack(alignment: .leading, spacing: ZyquoSpacing.sm) {30                    if let plan = controller.plan, !plan.items.isEmpty {31                        progressBar(plan)32                        checklist(plan)33                    } else {34                        emptyPlan35                    }36                }37                .padding(ZyquoSpacing.sm)38            }39            ZyquoHairline()40            meters41        }42        .frame(width: ZyquoMetrics.planPanelWidth)43        .background(ZyquoColor.background)44        .onReceive(clock) { tick in45            if controller.isRunning { now = tick }46        }47    }4849    // MARK: - Header5051    private var header: some View {52        HStack(spacing: ZyquoSpacing.xs) {53            Image(systemName: "checklist")54                .font(.system(size: 11))55                .foregroundStyle(ZyquoColor.accent)56            Text("Plan")57                .font(ZyquoFont.bodyEmphasis(size: 12.5))58                .foregroundStyle(ZyquoColor.textPrimary)59            Spacer(minLength: 0)60            if let plan = controller.plan, !plan.items.isEmpty {61                Text("\(plan.doneCount)/\(plan.items.count)")62                    .font(ZyquoFont.caption)63                    .foregroundStyle(ZyquoColor.textTertiary)64            }65        }66        .padding(.horizontal, ZyquoSpacing.sm)67        .frame(height: ZyquoSpacing.xxl)68    }6970    // MARK: - Checklist7172    private func progressBar(_ plan: TaskPlan) -> some View {73        ProgressView(value: Double(plan.doneCount), total: Double(max(plan.items.count, 1)))74            .progressViewStyle(.linear)75            .tint(ZyquoColor.accent)76            .animation(ZyquoMotion.appear, value: plan.doneCount)77    }7879    private func checklist(_ plan: TaskPlan) -> some View {80        VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {81            ForEach(plan.items) { item in82                PlanItemRow(item: item) { newTitle in83                    controller.renamePlanItem(id: item.id, to: newTitle)84                }85            }86        }87    }8889    private var emptyPlan: some View {90        VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {91            Text("No plan yet")92                .font(ZyquoFont.body(size: 12.5))93                .foregroundStyle(ZyquoColor.textSecondary)94            Text("The agent drafts a checklist here when a run starts, and checks items off as it works.")95                .font(ZyquoFont.caption)96                .foregroundStyle(ZyquoColor.textTertiary)97                .fixedSize(horizontal: false, vertical: true)98        }99        .padding(.top, ZyquoSpacing.xs)100    }101102    // MARK: - Budget meters103104    private var meters: some View {105        let config = controller.loopGuardConfiguration106        let elapsed = controller.runStartedAt.map { min(now.timeIntervalSince($0), config.wallClockBudget) } ?? 0107        return VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {108            meter(109                label: "Steps",110                value: Double(controller.stepsUsed),111                total: Double(config.maxSteps),112                text: "\(controller.stepsUsed)/\(config.maxSteps)"113            )114            meter(115                label: "Tokens",116                value: Double(controller.tokensUsed),117                total: Double(config.tokenBudget),118                text: tokenText119            )120            meter(121                label: "Time",122                value: elapsed,123                total: config.wallClockBudget,124                text: timeText(elapsed: elapsed, budget: config.wallClockBudget)125            )126        }127        .padding(ZyquoSpacing.sm)128    }129130    private func meter(label: String, value: Double, total: Double, text: String) -> some View {131        VStack(alignment: .leading, spacing: 2) {132            HStack {133                Text(label)134                    .font(ZyquoFont.caption)135                    .foregroundStyle(ZyquoColor.textSecondary)136                Spacer(minLength: 0)137                Text(text)138                    .font(ZyquoFont.caption)139                    .foregroundStyle(ZyquoColor.textTertiary)140            }141            ProgressView(value: min(value, total), total: max(total, 1))142                .progressViewStyle(.linear)143                .tint(value / max(total, 1) > 0.85 ? ZyquoColor.warning : ZyquoColor.accent)144                .controlSize(.small)145        }146    }147148    private var tokenText: String {149        let used = controller.tokensUsed150        let budget = controller.loopGuardConfiguration.tokenBudget151        func format(_ count: Int) -> String {152            count >= 1_000 ? String(format: "%.0fK", Double(count) / 1_000) : "\(count)"153        }154        return "\(format(used))/\(format(budget))"155    }156157    private func timeText(elapsed: TimeInterval, budget: TimeInterval) -> String {158        "\(Int(elapsed / 60))m/\(Int(budget / 60))m"159    }160}161162// MARK: - Row163164private struct PlanItemRow: View {165    let item: PlanItem166    var onRename: (String) -> Void167168    @State private var editing = false169    @State private var draft = ""170171    var body: some View {172        HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xs) {173            statusIcon174                .animation(ZyquoMotion.appear, value: item.status)175            VStack(alignment: .leading, spacing: 1) {176                if editing {177                    TextField("Step", text: $draft, onCommit: {178                        editing = false179                        onRename(draft)180                    })181                    .textFieldStyle(.plain)182                    .font(ZyquoFont.body(size: 12.5))183                } else {184                    Text(item.title)185                        .font(ZyquoFont.body(size: 12.5))186                        .foregroundStyle(item.status == .done ? ZyquoColor.textTertiary : ZyquoColor.textPrimary)187                        .strikethrough(item.status == .done, color: ZyquoColor.textTertiary)188                        .fixedSize(horizontal: false, vertical: true)189                        .onTapGesture(count: 2) {190                            draft = item.title191                            editing = true192                        }193                }194                if let note = item.note, !note.isEmpty {195                    Text(note)196                        .font(ZyquoFont.caption)197                        .foregroundStyle(item.status == .failed ? ZyquoColor.danger : ZyquoColor.textTertiary)198                        .fixedSize(horizontal: false, vertical: true)199                }200            }201            Spacer(minLength: 0)202        }203        .contentShape(Rectangle())204        .help("Double-click to edit")205    }206207    @ViewBuilder208    private var statusIcon: some View {209        switch item.status {210        case .pending:211            Image(systemName: "circle")212                .font(.system(size: 11))213                .foregroundStyle(ZyquoColor.textTertiary)214        case .active:215            Image(systemName: "circle.dotted.circle")216                .font(.system(size: 11))217                .foregroundStyle(ZyquoColor.accent)218        case .done:219            Image(systemName: "checkmark.circle.fill")220                .font(.system(size: 11))221                .foregroundStyle(ZyquoColor.success)222                .transition(.scale.combined(with: .opacity))223        case .failed:224            Image(systemName: "xmark.circle.fill")225                .font(.system(size: 11))226                .foregroundStyle(ZyquoColor.danger)227                .transition(.scale.combined(with: .opacity))228        }229    }230}231