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%
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