SwiftUI Tool Call Card Component
Names the action an agent is taking while it takes it, with an indeterminate bar that proves the app is alive without pretending to measure progress. Running, success and failure states; arguments and results stay collapsed behind a disclosure.
search_web
q3 saas churn benchmarks 2026
Variants: Running, Success, Failure
Install the free SwiftUI tool call card
- Add SwiftKitUI.swift to your app once. It holds the tokens and motion curves.
- Copy SKToolCallCard.swift below into your project. It needs nothing else.
Usage
Usage.swift
SKToolCallCard("search_web", summary: "Q3 revenue", state: .running)
SKToolCallCard("read_file", state: .success, detail: json, duration: 0.42)Full source · 244 lines · MIT
SKToolCallCard.swift
//
// SKToolCallCard.swift
// Swift Kit UI
//
// Requires: SwiftKitUI.swift
//
// What the agent is doing, while it does it.
//
// An assistant that calls tools goes quiet at exactly the moment it is working
// hardest. Users read that silence as a hang, and the fix is not a spinner —
// it is naming the action. "Running `search_web`" with a live bar is a
// different experience from three seconds of nothing, even though it is the
// same three seconds.
//
// The tool name is set in monospace deliberately: it is an identifier, not
// prose, and rendering it as prose invites people to read it as a sentence.
// Arguments and results stay collapsed, because in the common case nobody
// wants them and in the debugging case nothing else will do.
//
// Usage:
// SKToolCallCard("search_web", summary: "Q3 revenue", state: .running)
// SKToolCallCard("read_file", state: .success, detail: json, duration: 0.42)
//
import SwiftUI
public enum SKToolCallState: Sendable, Hashable {
case running, success, failure
}
public struct SKToolCallCard: View {
private let name: String
private let summary: String?
private let state: SKToolCallState
private let detail: String?
private let duration: TimeInterval?
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var expanded = false
@State private var sweep: CGFloat = -1
@State private var pulse = false
public init(
_ name: String,
summary: String? = nil,
state: SKToolCallState = .running,
detail: String? = nil,
duration: TimeInterval? = nil
) {
self.name = name
self.summary = summary
self.state = state
self.detail = detail
self.duration = duration
}
private var tint: Color {
switch state {
case .running: SK.Palette.accent
case .success: SK.Palette.success
case .failure: SK.Palette.danger
}
}
private var tintSoft: Color {
switch state {
case .running: SK.Palette.accentSoft
case .success: SK.Palette.successSoft
case .failure: SK.Palette.dangerSoft
}
}
private var symbol: String {
switch state {
case .running: "wrench.and.screwdriver.fill"
case .success: "checkmark"
case .failure: "exclamationmark.triangle.fill"
}
}
public var body: some View {
VStack(alignment: .leading, spacing: 0) {
Button {
guard detail != nil else { return }
expanded.toggle()
SK.Haptic.play(.light)
} label: {
headerRow
}
.buttonStyle(.skPress(scale: 0.995, haptic: nil))
.disabled(detail == nil)
if state == .running { progressBar }
if expanded, let detail {
ScrollView(.horizontal, showsIndicators: false) {
Text(detail)
.font(SK.Type_.monoCaption)
.foregroundStyle(SK.Palette.labelSecondary)
.textSelection(.enabled)
}
.padding(.horizontal, SK.Space.md)
.padding(.bottom, SK.Space.md)
.transition(.opacity)
}
}
.background(SK.Palette.surfaceElevated, in: RoundedRectangle(cornerRadius: SK.Radius.md, style: .continuous))
.overlay {
RoundedRectangle(cornerRadius: SK.Radius.md, style: .continuous)
.strokeBorder(state == .running ? tint.opacity(0.35) : SK.Palette.separator,
lineWidth: SK.Stroke.thin)
}
.skAnimation(.snap, value: expanded)
.skAnimation(.settle, value: state)
}
private var headerRow: some View {
HStack(spacing: SK.Space.md) {
ZStack {
RoundedRectangle(cornerRadius: SK.Radius.sm, style: .continuous)
.fill(tintSoft)
.frame(width: 30, height: 30)
Image(systemName: symbol)
.font(SK.Icon.symbol(SK.Icon.sm, weight: .bold))
.foregroundStyle(tint)
}
// A slow breath rather than a spin: the card already has a
// progress bar, and two things moving at different rates in one
// 30pt square reads as noise.
.scaleEffect(pulse ? 1.06 : 1)
.opacity(pulse ? 1 : 0.82)
VStack(alignment: .leading, spacing: 1) {
Text(name)
.font(SK.Type_.monoCaption)
.foregroundStyle(SK.Palette.label)
.lineLimit(1)
if let summary {
Text(summary)
.font(SK.Type_.caption)
.foregroundStyle(SK.Palette.labelTertiary)
.lineLimit(1)
}
}
Spacer(minLength: 0)
if let duration, state != .running {
Text(String(format: "%.2fs", duration))
.font(SK.Type_.caption)
.monospacedDigit()
.foregroundStyle(SK.Palette.labelTertiary)
}
if detail != nil {
Image(systemName: "chevron.down")
.font(SK.Icon.symbol(SK.Icon.xs, weight: .bold))
.foregroundStyle(SK.Palette.labelTertiary)
.rotationEffect(.degrees(expanded ? 180 : 0))
.skAnimation(.snap, value: expanded)
}
}
.padding(SK.Space.md)
.contentShape(Rectangle())
.accessibilityElement(children: .combine)
.accessibilityLabel("Tool \(name), \(accessibilityState)")
}
private var accessibilityState: String {
switch state {
case .running: "running"
case .success: "completed"
case .failure: "failed"
}
}
/// Indeterminate, because a tool call has no measurable progress. It exists
/// to prove the app is alive, which is a different job from reporting how
/// far along something is — so it never pretends to fill.
private var progressBar: some View {
GeometryReader { geo in
let width = geo.size.width
Capsule()
.fill(tint)
.frame(width: max(width * 0.34, 40))
.offset(x: reduceMotion ? width * 0.33 : sweep * width)
.opacity(reduceMotion ? 0.5 : 1)
}
.frame(height: 3)
.background(SK.Palette.fill)
.clipShape(Capsule())
.padding(.horizontal, SK.Space.md)
.padding(.bottom, SK.Space.md)
.onAppear {
guard !reduceMotion else { return }
sweep = -0.4
withAnimation(.easeInOut(duration: 1.15).repeatForever(autoreverses: false)) {
sweep = 1.05
}
withAnimation(.easeInOut(duration: 1.6).repeatForever(autoreverses: true)) {
pulse = true
}
}
.accessibilityHidden(true)
}
}
#Preview("SKToolCallCard") {
@Previewable @State var state: SKToolCallState = .running
VStack(spacing: SK.Space.md) {
SKToolCallCard(
"search_web",
summary: "q3 saas churn benchmarks 2026",
state: state,
detail: #"{ "query": "q3 saas churn benchmarks 2026", "top_k": 5 }"#,
duration: 1.24
)
SKToolCallCard(
"read_file",
summary: "Sources/Billing/Renewals.swift",
state: .success,
detail: "418 lines · 14.2 KB",
duration: 0.42
)
SKToolCallCard(
"run_query",
summary: "connection refused on replica-2",
state: .failure,
detail: "psql: error: connection to server at \"replica-2\" failed",
duration: 8.01
)
}
.padding(SK.Space.lg)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.background(SK.Palette.surface)
.task {
try? await Task.sleep(for: .seconds(3))
state = .success
}
}
Questions
- Is this SwiftUI tool call card free for commercial apps?
- Yes. Swift Kit UI is MIT licensed, so you can use it in personal and commercial apps with no payment or sign-up. Keep the licence notice with the code.
- What does it depend on?
- SKToolCallCard.swift depends only on SwiftKitUI.swift, a single foundation file with the design tokens and motion curves. There are no third-party packages.
- Which iOS versions does it support?
- iOS 17 and later, plus macOS 14.
Related components
Building a whole app? The Swift Kit adds auth, payments and onboarding, ready to ship.