// // StepCardView.swift // Zyquo Agent // // Author: Simon-Pierre Boucher // Mail: contact@spboucher.ai // // One AgentStep rendered as a live card: the collapsible thinking section // (dimmed), the thought line (streaming text), then each tool invocation as // a labeled chip + exact command in a monospace block + the streamed, // color-coded tool result (stdout/stderr, truncated with expand). Step cards // animate in with the family 150ms fade+rise. // import SwiftUI /// SF Symbol per tool wire name (chips, drawer, audit rows). func toolSymbolName(_ toolName: String) -> String { switch toolName { case "bash": return "terminal" case "osascript": return "applescript" case "read_file": return "doc.text" case "write_file": return "square.and.pencil" case "edit_file": return "pencil.line" case "list_dir": return "folder" case "search_files": return "text.magnifyingglass" case "update_plan": return "checklist" default: return "wrench.and.screwdriver" } } struct StepCardView: View { let step: LiveStep let fontSize: Double /// True while this step belongs to the in-flight run (streaming caret). var isLive: Bool = false @State private var showThinking = false var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xs) { header if !step.thinking.isEmpty { thinkingSection } if !step.text.isEmpty { if step.isFinalAnswer { MarkdownView(text: step.text, fontSize: fontSize) } else { Text(step.text) .font(ZyquoFont.body(size: fontSize)) .lineSpacing(fontSize * ZyquoFont.bodyLineSpacingFactor) .foregroundStyle(ZyquoColor.textPrimary) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } } if isLive, step.status == .streaming, step.text.isEmpty, step.invocations.isEmpty { StreamingCaret() } ForEach(step.invocations) { invocation in ToolInvocationView(invocation: invocation, fontSize: fontSize) } } .padding(ZyquoSpacing.sm) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .fill(ZyquoColor.surface) .overlay( RoundedRectangle(cornerRadius: ZyquoRadius.medium, style: .continuous) .strokeBorder(ZyquoColor.border, lineWidth: ZyquoMetrics.hairline) ) ) .transition(.opacity.combined(with: .offset(y: ZyquoMotion.appearRise))) } // MARK: Header private var header: some View { HStack(spacing: ZyquoSpacing.xs) { Text("Step \(step.index)") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) statusLabel Spacer(minLength: 0) if let input = step.inputTokens, let output = step.outputTokens { Text("\(input)→\(output) tok") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } } } @ViewBuilder private var statusLabel: some View { switch step.status { case .streaming: if isLive { HStack(spacing: ZyquoSpacing.xxs) { ProgressView().controlSize(.mini) Text("Thinking") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) } } case .awaitingApproval: Label("Awaiting approval", systemImage: "hand.raised") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.warning) case .executing: HStack(spacing: ZyquoSpacing.xxs) { if isLive { ProgressView().controlSize(.mini) } Text("Running tools") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textSecondary) } case .completed: EmptyView() case .failed: Label("Failed", systemImage: "xmark.circle") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.danger) case .cancelled: Label("Cancelled", systemImage: "slash.circle") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } } // MARK: Thinking private var thinkingSection: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { Button { withAnimation(ZyquoMotion.picker) { showThinking.toggle() } } label: { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: "chevron.right") .font(.system(size: 8, weight: .semibold)) .rotationEffect(.degrees(showThinking ? 90 : 0)) Text(isLive && step.status == .streaming && step.text.isEmpty ? "Thinking…" : "Thought process") .font(ZyquoFont.caption) } .foregroundStyle(ZyquoColor.textSecondary) } .buttonStyle(.plain) if showThinking { Text(step.thinking) .font(ZyquoFont.code(size: max(fontSize - 2, 1))) .foregroundStyle(ZyquoColor.textSecondary) .lineSpacing(3) .textSelection(.enabled) .padding(ZyquoSpacing.xs) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary) ) } } } } // MARK: - Tool invocation /// One tool call inside a step: chip + command block + streamed result. struct ToolInvocationView: View { let invocation: LiveInvocation let fontSize: Double @State private var expanded = false /// Output lines shown before the expand toggle appears. private static let collapsedLineLimit = 12 var body: some View { VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { chipRow // The plan tool's JSON is internal bookkeeping — the Plan panel // renders it; every other payload shows verbatim. if !invocation.displayPayload.isEmpty, invocation.name != "update_plan" { commandBlock } if !invocation.outputLines.isEmpty { outputBlock } else if let result = invocation.result, !result.content.isEmpty, invocation.name != "bash", invocation.name != "osascript" { resultSummary(result) } } } private var chipRow: some View { HStack(spacing: ZyquoSpacing.xs) { HStack(spacing: ZyquoSpacing.xxs) { Image(systemName: toolSymbolName(invocation.name)) .font(.system(size: 10, weight: .medium)) Text(invocation.name) .font(ZyquoFont.code(size: 11)) } .foregroundStyle(chipColor) .padding(.horizontal, ZyquoSpacing.xs) .padding(.vertical, 2) .background(Capsule().fill(chipColor.opacity(0.1))) switch invocation.phase { case .streaming: Text("composing…") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) case .executing: HStack(spacing: ZyquoSpacing.xxs) { ProgressView().controlSize(.mini) Text("running") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } case .finished: if let result = invocation.result { Image(systemName: result.isError ? "xmark.circle.fill" : "checkmark.circle.fill") .font(.system(size: 10)) .foregroundStyle(result.isError ? ZyquoColor.danger : ZyquoColor.success) } } if let decision = invocation.policyDecision, decision.ruling != .autoAllowed { ZyquoBadge(text: policyLabel(decision), color: ZyquoColor.textSecondary) } Spacer(minLength: 0) if let code = exitCodeText { Text(code) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.textTertiary) } } } private var commandBlock: some View { ScrollView(.horizontal, showsIndicators: false) { Text(invocation.displayPayload) .font(ZyquoFont.code(size: max(fontSize - 1, 1))) .foregroundStyle(ZyquoColor.textPrimary) .textSelection(.enabled) .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, ZyquoSpacing.xs) } .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary) ) } private var outputBlock: some View { let lines = invocation.outputLines let truncated = !expanded && lines.count > Self.collapsedLineLimit let visible = truncated ? Array(lines.suffix(Self.collapsedLineLimit)) : lines return VStack(alignment: .leading, spacing: 1) { if truncated { expandToggle(hiddenCount: lines.count - Self.collapsedLineLimit) } ForEach(visible) { line in Text(line.text) .font(ZyquoFont.code(size: max(fontSize - 1.5, 1))) .foregroundStyle(outputColor(for: line.kind)) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) } if expanded, lines.count > Self.collapsedLineLimit { collapseToggle } } .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, ZyquoSpacing.xs) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary.opacity(0.6)) ) } private func resultSummary(_ result: ToolResult) -> some View { let content = result.content let truncated = !expanded && content.count > 600 let visible = truncated ? String(content.prefix(600)) + "…" : content return VStack(alignment: .leading, spacing: ZyquoSpacing.xxs) { Text(visible) .font(ZyquoFont.code(size: max(fontSize - 1.5, 1))) .foregroundStyle(result.isError ? ZyquoColor.danger : ZyquoColor.textSecondary) .textSelection(.enabled) .fixedSize(horizontal: false, vertical: true) if truncated { Button("Show all") { withAnimation(ZyquoMotion.appear) { expanded = true } } .buttonStyle(.plain) .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.accent) } } .padding(.horizontal, ZyquoSpacing.sm) .padding(.vertical, ZyquoSpacing.xs) .frame(maxWidth: .infinity, alignment: .leading) .background( RoundedRectangle(cornerRadius: ZyquoRadius.small, style: .continuous) .fill(ZyquoColor.surfaceSecondary.opacity(0.6)) ) } private func expandToggle(hiddenCount: Int) -> some View { Button { withAnimation(ZyquoMotion.appear) { expanded = true } } label: { Text("… \(hiddenCount) earlier line\(hiddenCount == 1 ? "" : "s") — show all") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.accent) } .buttonStyle(.plain) } private var collapseToggle: some View { Button { withAnimation(ZyquoMotion.appear) { expanded = false } } label: { Text("Collapse output") .font(ZyquoFont.caption) .foregroundStyle(ZyquoColor.accent) } .buttonStyle(.plain) } // MARK: Helpers private var chipColor: Color { if let result = invocation.result, result.isError { return ZyquoColor.danger } return ZyquoColor.accent } private var exitCodeText: String? { guard let result = invocation.result, invocation.name == "bash" || invocation.name == "osascript" else { return nil } if let code = invocation.exitCode { return "exit \(code)" } // ShellTool reports the exit code inside the result text; surface // errors without duplicating it. return result.isError ? "failed" : nil } private func policyLabel(_ decision: PolicyDecisionRecord) -> String { switch decision.ruling { case .autoAllowed: return "auto-allowed" case .approvedByUser: return "approved" case .editedAndApproved: return "edited & approved" case .denied: return "denied" } } private func outputColor(for kind: TerminalLine.Kind) -> Color { switch kind { case .stderr: return ZyquoColor.danger case .note, .meta: return ZyquoColor.textTertiary case .stdout, .command: return ZyquoColor.textSecondary } } } /// Blinking caret shown at the tail of streaming content (family standard). struct StreamingCaret: View { @State private var visible = true var body: some View { RoundedRectangle(cornerRadius: 1) .fill(ZyquoColor.accent) .frame(width: 7, height: 15) .opacity(visible ? 1 : 0.15) .onAppear { withAnimation(.easeInOut(duration: 0.55).repeatForever(autoreverses: true)) { visible = false } } } }