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%
14.2 KB · 379 lines swift
Raw Blame History
1//2//  StepCardView.swift3//  Zyquo Agent4//5//  Author: Simon-Pierre Boucher6//  Mail: contact@spboucher.ai7//8//  One AgentStep rendered as a live card: the collapsible thinking section9//  (dimmed), the thought line (streaming text), then each tool invocation as10//  a labeled chip + exact command in a monospace block + the streamed,11//  color-coded tool result (stdout/stderr, truncated with expand). Step cards12//  animate in with the family 150ms fade+rise.13//1415import SwiftUI1617/// SF Symbol per tool wire name (chips, drawer, audit rows).18func toolSymbolName(_ toolName: String) -> String {19    switch toolName {20    case "bash": return "terminal"21    case "osascript": return "applescript"22    case "read_file": return "doc.text"23    case "write_file": return "square.and.pencil"24    case "edit_file": return "pencil.line"25    case "list_dir": return "folder"26    case "search_files": return "text.magnifyingglass"27    case "update_plan": return "checklist"28    default: return "wrench.and.screwdriver"29    }30}3132struct StepCardView: View {33    let step: LiveStep34    let fontSize: Double35    /// True while this step belongs to the in-flight run (streaming caret).36    var isLive: Bool = false3738    @State private var showThinking = false3940    var body: some View {41        VStack(alignment: .leading, spacing: ZyquoSpacing.xs) {42            header43            if !step.thinking.isEmpty {44                thinkingSection45            }46            if !step.text.isEmpty {47                if step.isFinalAnswer {48                    MarkdownView(text: step.text, fontSize: fontSize)49                } else {50                    Text(step.text)51                        .font(ZyquoFont.body(size: fontSize))52                        .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor)53                        .foregroundStyle(ZyquoColor.textPrimary)54                        .textSelection(.enabled)55                        .fixedSize(horizontal: false, vertical: true)56                }57            }58            if isLive, step.status == .streaming, step.text.isEmpty, step.invocations.isEmpty {59                StreamingCaret()60            }61            ForEach(step.invocations) { invocation in62                ToolInvocationView(invocation: invocation, fontSize: fontSize)63            }64        }65        .padding(ZyquoSpacing.sm)66        .frame(maxWidth: .infinity, alignment: .leading)67        .background(68            RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)69                .fill(ZyquoColor.surface)70                .overlay(71                    RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous)72                        .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline)73                )74        )75        .transition(.opacity.combined(with: .offset(y: ZyquoMotion.appearRise)))76    }7778    // MARK: Header7980    private var header: some View {81        HStack(spacing: ZyquoSpacing.xs) {82            Text("Step \(step.index)")83                .font(ZyquoFont.caption)84                .foregroundStyle(ZyquoColor.textTertiary)85            statusLabel86            Spacer(minLength: 0)87            if let input = step.inputTokens, let output = step.outputTokens {88                Text("\(input)\(output) tok")89                    .font(ZyquoFont.caption)90                    .foregroundStyle(ZyquoColor.textTertiary)91            }92        }93    }9495    @ViewBuilder96    private var statusLabel: some View {97        switch step.status {98        case .streaming:99            if isLive {100                HStack(spacing: ZyquoSpacing.xxs) {101                    ProgressView().controlSize(.mini)102                    Text("Thinking")103                        .font(ZyquoFont.caption)104                        .foregroundStyle(ZyquoColor.textSecondary)105                }106            }107        case .awaitingApproval:108            Label("Awaiting approval", systemImage: "hand.raised")109                .font(ZyquoFont.caption)110                .foregroundStyle(ZyquoColor.warning)111        case .executing:112            HStack(spacing: ZyquoSpacing.xxs) {113                if isLive { ProgressView().controlSize(.mini) }114                Text("Running tools")115                    .font(ZyquoFont.caption)116                    .foregroundStyle(ZyquoColor.textSecondary)117            }118        case .completed:119            EmptyView()120        case .failed:121            Label("Failed", systemImage: "xmark.circle")122                .font(ZyquoFont.caption)123                .foregroundStyle(ZyquoColor.danger)124        case .cancelled:125            Label("Cancelled", systemImage: "slash.circle")126                .font(ZyquoFont.caption)127                .foregroundStyle(ZyquoColor.textTertiary)128        }129    }130131    // MARK: Thinking132133    private var thinkingSection: some View {134        VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {135            Button {136                withAnimation(ZyquoMotion.picker) { showThinking.toggle() }137            } label: {138                HStack(spacing: ZyquoSpacing.xxs) {139                    Image(systemName: "chevron.right")140                        .font(.system(size: 8, weight: .semibold))141                        .rotationEffect(.degrees(showThinking ? 90 : 0))142                    Text(isLive && step.status == .streaming && step.text.isEmpty ? "Thinking…" : "Thought process")143                        .font(ZyquoFont.caption)144                }145                .foregroundStyle(ZyquoColor.textSecondary)146            }147            .buttonStyle(.plain)148            if showThinking {149                Text(step.thinking)150                    .font(ZyquoFont.code(size: max(fontSize - 2, 1)))151                    .foregroundStyle(ZyquoColor.textSecondary)152                    .lineSpacing(3)153                    .textSelection(.enabled)154                    .padding(ZyquoSpacing.xs)155                    .frame(maxWidth: .infinity, alignment: .leading)156                    .background(157                        RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)158                            .fill(ZyquoColor.surfaceSecondary)159                    )160            }161        }162    }163}164165// MARK: - Tool invocation166167/// One tool call inside a step: chip + command block + streamed result.168struct ToolInvocationView: View {169    let invocation: LiveInvocation170    let fontSize: Double171172    @State private var expanded = false173174    /// Output lines shown before the expand toggle appears.175    private static let collapsedLineLimit = 12176177    var body: some View {178        VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {179            chipRow180            // The plan tool's JSON is internal bookkeeping — the Plan panel181            // renders it; every other payload shows verbatim.182            if !invocation.displayPayload.isEmpty, invocation.name != "update_plan" {183                commandBlock184            }185            if !invocation.outputLines.isEmpty {186                outputBlock187            } else if let result = invocation.result, !result.content.isEmpty,188                      invocation.name != "bash", invocation.name != "osascript" {189                resultSummary(result)190            }191        }192    }193194    private var chipRow: some View {195        HStack(spacing: ZyquoSpacing.xs) {196            HStack(spacing: ZyquoSpacing.xxs) {197                Image(systemName: toolSymbolName(invocation.name))198                    .font(.system(size: 10, weight: .medium))199                Text(invocation.name)200                    .font(ZyquoFont.code(size: 11))201            }202            .foregroundStyle(chipColor)203            .padding(.horizontal, ZyquoSpacing.xs)204            .padding(.vertical, 2)205            .background(Capsule().fill(chipColor.opacity(0.1)))206207            switch invocation.phase {208            case .streaming:209                Text("composing…")210                    .font(ZyquoFont.caption)211                    .foregroundStyle(ZyquoColor.textTertiary)212            case .executing:213                HStack(spacing: ZyquoSpacing.xxs) {214                    ProgressView().controlSize(.mini)215                    Text("running")216                        .font(ZyquoFont.caption)217                        .foregroundStyle(ZyquoColor.textTertiary)218                }219            case .finished:220                if let result = invocation.result {221                    Image(systemName: result.isError ? "xmark.circle.fill" : "checkmark.circle.fill")222                        .font(.system(size: 10))223                        .foregroundStyle(result.isError ? ZyquoColor.danger : ZyquoColor.success)224                }225            }226            if let decision = invocation.policyDecision, decision.ruling != .autoAllowed {227                ZyquoBadge(text: policyLabel(decision), color: ZyquoColor.textSecondary)228            }229            Spacer(minLength: 0)230            if let code = exitCodeText {231                Text(code)232                    .font(ZyquoFont.caption)233                    .foregroundStyle(ZyquoColor.textTertiary)234            }235        }236    }237238    private var commandBlock: some View {239        ScrollView(.horizontal, showsIndicators: false) {240            Text(invocation.displayPayload)241                .font(ZyquoFont.code(size: max(fontSize - 1, 1)))242                .foregroundStyle(ZyquoColor.textPrimary)243                .textSelection(.enabled)244                .padding(.horizontal, ZyquoSpacing.sm)245                .padding(.vertical, ZyquoSpacing.xs)246        }247        .background(248            RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)249                .fill(ZyquoColor.surfaceSecondary)250        )251    }252253    private var outputBlock: some View {254        let lines = invocation.outputLines255        let truncated = !expanded && lines.count > Self.collapsedLineLimit256        let visible = truncated ? Array(lines.suffix(Self.collapsedLineLimit)) : lines257        return VStack(alignment: .leading, spacing: 1) {258            if truncated {259                expandToggle(hiddenCount: lines.count - Self.collapsedLineLimit)260            }261            ForEach(visible) { line in262                Text(line.text)263                    .font(ZyquoFont.code(size: max(fontSize - 1.5, 1)))264                    .foregroundStyle(outputColor(for: line.kind))265                    .textSelection(.enabled)266                    .fixedSize(horizontal: false, vertical: true)267            }268            if expanded, lines.count > Self.collapsedLineLimit {269                collapseToggle270            }271        }272        .padding(.horizontal, ZyquoSpacing.sm)273        .padding(.vertical, ZyquoSpacing.xs)274        .frame(maxWidth: .infinity, alignment: .leading)275        .background(276            RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)277                .fill(ZyquoColor.surfaceSecondary.opacity(0.6))278        )279    }280281    private func resultSummary(_ result: ToolResult) -> some View {282        let content = result.content283        let truncated = !expanded && content.count > 600284        let visible = truncated ? String(content.prefix(600)) + "…" : content285        return VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) {286            Text(visible)287                .font(ZyquoFont.code(size: max(fontSize - 1.5, 1)))288                .foregroundStyle(result.isError ? ZyquoColor.danger : ZyquoColor.textSecondary)289                .textSelection(.enabled)290                .fixedSize(horizontal: false, vertical: true)291            if truncated {292                Button("Show all") { withAnimation(ZyquoMotion.appear) { expanded = true } }293                    .buttonStyle(.plain)294                    .font(ZyquoFont.caption)295                    .foregroundStyle(ZyquoColor.accent)296            }297        }298        .padding(.horizontal, ZyquoSpacing.sm)299        .padding(.vertical, ZyquoSpacing.xs)300        .frame(maxWidth: .infinity, alignment: .leading)301        .background(302            RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous)303                .fill(ZyquoColor.surfaceSecondary.opacity(0.6))304        )305    }306307    private func expandToggle(hiddenCount: Int) -> some View {308        Button {309            withAnimation(ZyquoMotion.appear) { expanded = true }310        } label: {311            Text("… \(hiddenCount) earlier line\(hiddenCount == 1 ? "" : "s") — show all")312                .font(ZyquoFont.caption)313                .foregroundStyle(ZyquoColor.accent)314        }315        .buttonStyle(.plain)316    }317318    private var collapseToggle: some View {319        Button {320            withAnimation(ZyquoMotion.appear) { expanded = false }321        } label: {322            Text("Collapse output")323                .font(ZyquoFont.caption)324                .foregroundStyle(ZyquoColor.accent)325        }326        .buttonStyle(.plain)327    }328329    // MARK: Helpers330331    private var chipColor: Color {332        if let result = invocation.result, result.isError { return ZyquoColor.danger }333        return ZyquoColor.accent334    }335336    private var exitCodeText: String? {337        guard let result = invocation.result,338              invocation.name == "bash" || invocation.name == "osascript" else { return nil }339        if let code = invocation.exitCode { return "exit \(code)" }340        // ShellTool reports the exit code inside the result text; surface341        // errors without duplicating it.342        return result.isError ? "failed" : nil343    }344345    private func policyLabel(_ decision: PolicyDecisionRecord) -> String {346        switch decision.ruling {347        case .autoAllowed: return "auto-allowed"348        case .approvedByUser: return "approved"349        case .editedAndApproved: return "edited & approved"350        case .denied: return "denied"351        }352    }353354    private func outputColor(for kind: TerminalLine.Kind) -> Color {355        switch kind {356        case .stderr: return ZyquoColor.danger357        case .note, .meta: return ZyquoColor.textTertiary358        case .stdout, .command: return ZyquoColor.textSecondary359        }360    }361}362363/// Blinking caret shown at the tail of streaming content (family standard).364struct StreamingCaret: View {365    @State private var visible = true366367    var body: some View {368        RoundedRectangle(cornerRadius: 1)369            .fill(ZyquoColor.accent)370            .frame(width: 7, height: 15)371            .opacity(visible ? 1 : 0.15)372            .onAppear {373                withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) {374                    visible = false375                }376            }377    }378}379