| name | swiftcui |
| description | Reference guidance for building, changing, explaining, and verifying SwiftCUI apps. Use this skill whenever the work involves SwiftCUI screens or views, Action definitions, tab or navigation flows, wiring `.actionHandler(.tabs, ...)`, or validating behavior through this skill's bundled `scui` script, even if the user does not explicitly mention the skill name. |
SwiftCUI Guide
Use this skill as reference material when working on SwiftCUI code or when you need to verify a SwiftCUI app through its TCP interface.
SwiftCUI is a declarative framework for CUI applications in Swift. A SwiftCUI app runs as a TCP server, and this skill's bundled scui script sends request-response commands such as view and action. That makes the UI easy for AI agents to inspect and verify without relying on interactive terminal sessions.
Keep the UI layer thin and share ViewModels or other application logic between CUI and GUI where possible. That keeps behavior consistent and makes verification cheaper.
Decide First
Before responding, decide which of these situations you are in:
- The user is implementing or changing a screen: focus on
View, Action, lifecycle, and navigation guidance.
- The user is adding or fixing top-level switching: focus on
TabActionHandler, .actionHandler(.tabs, ...), and navigator.activateStack(...).
- The user is trying to verify behavior or reproduce a UI flow: focus on running the app and using the bundled
scui script.
- The user is asking how SwiftCUI works: explain only the relevant sections instead of dumping the whole guide.
Additional Resources
- scripts/scui: this skill's bundled script that sends
view and action requests to a running SwiftCUI app over TCP. Read or run it when you need to verify behavior, reproduce a navigation flow, or give the user exact commands to inspect the current UI state.
Core Concepts
- One screen is a
View.
View.body returns display output as a Component, not another View.
- Users interact by sending an
Action.
actions should expose only the operations that are valid in the current state.
perform(_:) handles state changes and navigation for one action.
- Screen navigation uses
navigator.present(), navigator.dismiss(), and navigator.dismissAll().
- Top-level tab switching uses
.actionHandler(.tabs, ...) and TabActionHandler.
- The application starts from
Application, which runs the TCP server used by scui.
Working with View
Treat View as the screen-level unit. Put rendering in body, current affordances in actions, and state transitions in perform(_:). Keeping those roles separate makes the screen easier to understand and easier to verify through scui.
- Put the strings or child components you want to show in
body.
- Use
if, else, and for directly in body to reflect current state.
- Return only currently valid actions from
actions so the rendered hints match the actual UI state.
- Keep
perform(_:) focused on applying the selected action and then updating navigation or state.
onAppear() and task()
Use lifecycle hooks according to how the work behaves:
onAppear(): initial work required before the first rendered result is returned.
task(): ongoing background work that should continue after rendering, such as polling or long-lived observation.
Prefer onAppear() for initial loading. Reach for task() only when the work should continue in the background.
This example shows a single screen that renders state, exposes only available actions, and performs navigation after state changes.
import SwiftCUI
struct SearchView: View {
@Environment(\.navigator) var navigator
let viewModel = SearchViewModel()
var body: some Component {
"# Search"
""
if viewModel.isLoading {
"Searching..."
} else if let error = viewModel.errorMessage {
"Error: \(error)"
} else if viewModel.items.isEmpty {
"No results. Use 'search' action."
} else {
for (index, item) in viewModel.items.enumerated() {
"\(index). \(item.name)"
}
}
}
enum Action: ActionProtocol {
case search(query: String)
case detail(index: Int)
var hint: String {
switch self {
case .search: "Search items"
case .detail: "Open detail"
}
}
}
var actions: [Action] {
var actions: [Action] = [.search(query: "...")]
if !viewModel.items.isEmpty {
actions.append(.detail(index: 0))
}
return actions
}
func onAppear() async {
await viewModel.loadInitialData()
}
func perform(_ action: Action) async {
switch action {
case .search(let query):
viewModel.query = query
await viewModel.search()
case .detail(let index):
guard viewModel.items.indices.contains(index) else { return }
navigator.present(DetailView(id: viewModel.items[index].id))
}
}
}
Defining Action
Use an enum for Action in most cases because it maps cleanly to JSON and keeps screen interactions explicit. Implement hint so the rendered action list tells the user or agent what each operation does.
enum Action: ActionProtocol {
case refresh
case search(query: String)
case detail(index: Int)
var hint: String {
switch self {
case .refresh: "Refresh data"
case .search: "Search items"
case .detail: "Open detail"
}
}
}
Screen Navigation
Get navigator from @Environment(\.navigator) and call it from perform(_:) when the user action should change the stack.
navigator.present(DetailView(id: item.id))
navigator.dismiss()
navigator.dismissAll()
- Use
present to move deeper into the current stack.
- Use
dismiss to go back one screen.
- Use
dismissAll to return to the root of the current stack.
Using Tabs with TabActionHandler
Use TabActionHandler when the app needs multiple top-level stacks such as Search, Trending, and User. Keep the responsibilities separated:
tabs: map each tab action to its root view.
actions: expose the selectable tabs.
perform(_:): switch stacks, usually through navigator.activateStack(...).
The tabs themselves are rendered separately, so the handler does not need a body.
This example shows the standard top-level tab handler for three root screens.
import SwiftCUI
struct Tabs: TabActionHandler {
@Environment(\.navigator) var navigator
enum Action: String, ActionProtocol, Hashable {
case search, trending, user
var hint: String {
switch self {
case .search: "Go to Search"
case .trending: "Go to Trending"
case .user: "Go to User"
}
}
}
var tabs: [Action: any View] {
[
.search: SearchView(),
.trending: TrendingView(),
.user: UserView(),
]
}
var actions: [Action] { [.search, .trending, .user] }
func perform(_ action: Action) async {
navigator.activateStack(action)
}
}
Attach the tab handler at the root when you create the app.
let app = Application(rootView: SearchView().actionHandler(.tabs, Tabs()))
try await app.run()
Running and Verifying the App
After changing SwiftCUI screens, actions, or tab wiring, verify the behavior when possible. SwiftCUI is designed so the app can be inspected through deterministic command calls instead of an interactive terminal session.
Start the executable target that runs the app:
let app = Application(rootView: SearchView())
try await app.run()
swift run AppName
From any working directory, use the bundled script through ${CLAUDE_SKILL_DIR}:
${CLAUDE_SKILL_DIR}/scripts/scui view
${CLAUDE_SKILL_DIR}/scripts/scui action '{"search":{"query":"swift"}}'
${CLAUDE_SKILL_DIR}/scripts/scui action '{"detail":{"index":0}}'
${CLAUDE_SKILL_DIR}/scripts/scui action --target tabs '"trending"'
Use these checks when they help:
view: inspect the current rendered output.
action <json>: trigger a screen action and inspect the resulting state.
action --target tabs <json>: switch top-level stacks and verify tab wiring.
The default port is 41917. If the app uses a different port, pass --port.
If you want a shorter command name in the current shell, add the skill's script directory to PATH:
export PATH="${CLAUDE_SKILL_DIR}/scripts:$PATH"
Then you can run:
scui view
Tips
View and TabActionHandler are already @MainActor, so conforming types usually do not need to repeat it.
- Keep ViewModels shared between CUI and GUI when possible so both surfaces exercise the same behavior.
- Let ViewModel methods own their error handling unless the View truly needs to branch on the result.
onAppear() and perform(_:) are async, so direct await is usually the clearest default.
- If a shared ViewModel is also used from the GUI side, the View may still need to start a
Task for button-driven work. Use Task.immediate when synchronous UI control matters.