// // PlanPanelView.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // The right-hand Plan/Todo panel (~300pt, collapsible): the live checklist // the agent maintains via `update_plan` — items animate through pending/ // active/done/failed, titles are user-editable — plus a progress bar and the // LoopGuard meters (steps, tokens, elapsed time vs. budgets). // import Combine import SwiftUI struct PlanPanelView: View { @ObservedObject var controller: RunController /// Ticks the elapsed-time meter while a run is live. @State private var now = Date() private let clock = Timer.publish(every: 1, on: .main, in: .common).autoconnect() var body: some View { VStack(alignment: .leading, spacing: 0) { header ZyquoHairline() ScrollView { VStack(alignment: .leading, spacing: ZyquoSpacing.sm) { if let plan = controller.plan, !plan.items.isEmpty { progressBar(plan) checklist(plan) } else { emptyPlan } } .padding(ZyquoSpacing.sm) } ZyquoHairline() meters } .frame(width: ZyquoMetrics.planPanelWidth) .background(ZyquoColor.background) .onReceive(clock) { tick in if controller.isRunning { now = tick } } } // MARK: - Header private var header: some View { HStack(spacing: ZyquoSpacing.xs) { Image(systemName: "checklist") .font(.system(size: 11)) .foregroundStyle(ZyquoColor.accent) Text("Plan") .font(ZyquoFont.bodyEmphasis(size: 12.5)) .foregroundStyle(ZyquoColor.textPrimary) Spacer(minLength: 0) if let plan = controller.plan, !plan.items.isEmpty { Text("\(plan.doneCount)/\(plan.items.count)") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } } .padding(.horizontal, ZyquoSpacing.sm) .frame(height: ZyquoSpacing.xxl) } // MARK: - Checklist private func progressBar(_ plan: TaskPlan) -> some View { ProgressView(value: Double(plan.doneCount), total: Double(max(plan.items.count, 1))) .progressViewStyle(.linear) .tint(ZyquoColor.accent) .animation(ZyquoMotion.appear, value: plan.doneCount) } private func checklist(_ plan: TaskPlan) -> some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { ForEach(plan.items) { item in PlanItemRow(item: item) { newTitle in controller.renamePlanItem(id: item.id, to: newTitle) } } } } private var emptyPlan: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { Text("No plan yet") .font(ZyquoFont.body(size: 12.5)) .foregroundStyle(ZyquoColor.textSecondary) Text("The agent drafts a checklist here when a run starts, and checks items off as it works.") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) .fixedSize(horizontal: false, vertical: true) } .padding(.top, ZyquoSpacing.xs) } // MARK: - Budget meters private var meters: some View { let config = controller.loopGuardConfiguration let elapsed = controller.runStartedAt.map { min(now.timeIntervalSince($0), config.wallClockBudget) } ?? 0 return VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { meter( label: "Steps", value: Double(controller.stepsUsed), total: Double(config.maxSteps), text: "\(controller.stepsUsed)/\(config.maxSteps)" ) meter( label: "Tokens", value: Double(controller.tokensUsed), total: Double(config.tokenBudget), text: tokenText ) meter( label: "Time", value: elapsed, total: config.wallClockBudget, text: timeText(elapsed: elapsed, budget: config.wallClockBudget) ) } .padding(ZyquoSpacing.sm) } private func meter(label: String, value: Double, total: Double, text: String) -> some View { VStack(alignment: .leading, spacing: 2) { HStack { Text(label) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) Spacer(minLength: 0) Text(text) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } ProgressView(value: min(value, total), total: max(total, 1)) .progressViewStyle(.linear) .tint(value / max(total, 1) > 0.85 ? ZyquoColor.warning : ZyquoColor.accent) .controlSize(.small) } } private var tokenText: String { let used = controller.tokensUsed let budget = controller.loopGuardConfiguration.tokenBudget func format(_ count: Int) -> String { count >= 1_000 ? String(format: "%.0fK", Double(count) / 1_000) : "\(count)" } return "\(format(used))/\(format(budget))" } private func timeText(elapsed: TimeInterval, budget: TimeInterval) -> String { "\(Int(elapsed / 60))m/\(Int(budget / 60))m" } } // MARK: - Row private struct PlanItemRow: View { let item: PlanItem var onRename: (String) -> Void @State private var editing = false @State private var draft = "" var body: some View { HStack(alignment: .firstTextBaseline, spacing: ZyquoSpacing.xs) { statusIcon .animation(ZyquoMotion.appear, value: item.status) VStack(alignment: .leading, spacing: 1) { if editing { TextField("Step", text: $draft, onCommit: { editing = false onRename(draft) }) .textFieldStyle(.plain) .font(ZyquoFont.body(size: 12.5)) } else { Text(item.title) .font(ZyquoFont.body(size: 12.5)) .foregroundStyle(item.status == .done ? ZyquoColor.textTertiary : ZyquoColor.textPrimary) .strikethrough(item.status == .done, color: ZyquoColor.textTertiary) .fixedSize(horizontal: false, vertical: true) .onTapGesture(count: 2) { draft = item.title editing = true } } if let note = item.note, !note.isEmpty { Text(note) .font(ZyquoFont.caption) .foregroundStyle(item.status == .failed ? ZyquoColor.danger : ZyquoColor.textTertiary) .fixedSize(horizontal: false, vertical: true) } } Spacer(minLength: 0) } .contentShape(Rectangle()) .help("Double-click to edit") } @ViewBuilder private var statusIcon: some View { switch item.status { case .pending: Image(systemName: "circle") .font(.system(size: 11)) .foregroundStyle(ZyquoColor.textTertiary) case .active: Image(systemName: "circle.dotted.circle") .font(.system(size: 11)) .foregroundStyle(ZyquoColor.accent) case .done: Image(systemName: "checkmark.circle.fill") .font(.system(size: 11)) .foregroundStyle(ZyquoColor.success) .transition(.scale.combined(with: .opacity)) case .failed: Image(systemName: "xmark.circle.fill") .font(.system(size: 11)) .foregroundStyle(ZyquoColor.danger) .transition(.scale.combined(with: .opacity)) } } }